Write your own scaffold¶
A scaffold is a choice of six components plus an optional bit of adaptation. There are two ways to
build one: compose components inline (no new files), or subclass GalapagosScaffold and ship
a card so others can load it by name.
Way 1 — build-your-own from components¶
Pass component instances (or import paths) straight to GalapagosScaffold.from_card. Any role you
omit gets a sensible default; the Evaluator always comes from the task.
import galapagos as gx
from galapagos.components import (
InMemoryPopulation, ExploreExploitPolicy, DefaultPromptBuilder, LLMProposer, NullMemory,
)
scaffold = gx.GalapagosScaffold.from_card(
population=InMemoryPopulation(capacity=200),
selection_policy=ExploreExploitPolicy(seed=0, explore_ratio=0.4, num_inspirations=3),
prompt_builder=DefaultPromptBuilder(),
proposer=LLMProposer(),
memory=NullMemory(),
model=gx.load_model("openai/gpt-4o-mini", host="openrouter"),
)
result = scaffold.run(task=gx.load_task("function_minimization"), max_iterations=30)
print(result.best_score)
Each of population, selection_policy, prompt_builder, proposer, evaluator, memory may be:
- a component instance (as above),
- a dotted
module.Classpath (e.g."galapagos.components.UCBBanditPolicy"), or - a
.pyfile path containing exactly one subclass of the right base class.
This is the fastest way to prototype: swap one slot (say, UCBBanditPolicy for
ExploreExploitPolicy) and re-run. See the six components for every
shipped implementation.
Way 2 — subclass + register + ship a card¶
For a reusable method, subclass GalapagosScaffold, override build_components, and register it.
This is exactly how the bundled OpenEvolveScaffold and AdaEvolveScaffold are built.
The class¶
# my_scaffold/scaffold.py
from galapagos.scaffolds import GalapagosScaffold, register_scaffold
from galapagos.components import (
IslandPopulation, UCBBanditPolicy, DefaultPromptBuilder, LLMProposer, ScratchpadMemory,
)
from galapagos.config import GalapagosConfig
from galapagos.models import GalapagosModel
from galapagos.records import Genome
@register_scaffold("banditevolve")
class BanditEvolveScaffold(GalapagosScaffold):
name = "banditevolve"
@classmethod
def build_components(cls, config: GalapagosConfig, model: GalapagosModel | None) -> dict:
"""Return the five scaffold-side components. The Evaluator comes from the task."""
seed = int(config.seed)
n = int(config.population.num_islands)
return {
"population": IslandPopulation(num_islands=n, migration_interval=20),
"selection_policy": UCBBanditPolicy(seed=seed, num_islands=n,
num_inspirations=config.selection_policy.num_inspirations),
"prompt_builder": DefaultPromptBuilder(),
"proposer": LLMProposer(),
"memory": ScratchpadMemory(max_notes=8),
}
# ---- optional adaptation hooks (no-ops by default) ----
def before_step(self) -> None:
"""Runs before selection each step — adjust intensity, ε, mode, ..."""
def after_step(self, child: Genome, result) -> None:
"""Runs after a child is evaluated (result is None on a no-op step).
Here: periodically distill the best feedback into the meta-scratchpad."""
interval = 10
if result is None or self.ctx.iteration % interval != 0:
return
best = self.ctx.best
feedback = best.artifacts.get("text_feedback") if best else None
if feedback:
self.memory.write(f"iter {self.ctx.iteration}: best={best.fitness:.4f} — {feedback}")
def periodic(self) -> None:
"""Runs once per iteration, after the step — strategy refresh, migration triggers, ..."""
@register_scaffold("banditevolve") wires the class to its card name and adds it to
registered_scaffolds(). Importing the module is enough to register it.
The hooks¶
The base loop is fixed; subclasses adapt through three no-op hooks called each step:
| Hook | When | Typical use |
|---|---|---|
before_step() |
before selection | mode switching, intensity / ε schedules |
after_step(child, result) |
after the child is evaluated (result is None on a no-op) |
bandit credit, stagnation response, memory writes |
periodic() |
once per iteration, after the step | meta-scratchpad refresh, strategy co-evolution, migration |
You also have the loop context on self: self.ctx (a LoopContext: iteration, cost_usd,
best, blackboard), self.population, self.memory, self.config, self.model, and self._stale
(iterations since the last best-score improvement).
Record custom evolutionary behavior in ETIF¶
The base loop automatically records selection, candidate model calls, proposals, evaluations, admission, eviction, checkpoints, and run lifecycle. Emit an event only for additional behavior that changes future search—for example migration, a strategy swap, a memory update, or an agent/tool turn that produces no candidate:
self._emit_event(
event_type="population",
action="migrate",
status="completed",
component_role="population",
evolution_track="solution",
inputs=[self._event_ref("candidate", source.id, "source_candidate")],
new_candidates=[{
"candidate_id": migrant.id,
"candidate_type": "solution",
"generation": source.metadata.get("generation", 0) + 1,
"parent_ids": [source.id],
"content": migrant.content,
"attributes": {"partition_index": target_island},
}],
details={
"source_location": source_island,
"destination_location": target_island,
"reason": "scheduled_ring_migration",
},
)
A component that only has LoopContext can call ctx.emit_event(...) with the same arguments.
Use one of the core categories (run, selection, model_call, proposal, evaluation,
population, adaptation, agent, tool, memory, checkpoint) and a verb for action.
Do not put the scaffold name in event_type. Keep portable facts in details; put raw
method-specific state under a namespaced extensions key. If an event creates an evolvable object,
pass it through new_candidates with candidate_type, plural parent_ids, and content; the writer
will assign its created_by_event_id and output reference.
For an executed tool, use event_type="tool" and action="execute". Its portable details are
validated by galapagos.trajectory.ToolEventDetails: tool_name plus optional
model_call_id, tool_call_id, call_index, round, tool_type, arbitrary JSON
arguments/result, and optional result_artifact/result_media_type. Put success or failure,
duration, and the structured error on the common event envelope rather than inventing tool-specific
status fields.
See Trajectories (ETIF) for the common detail vocabulary and inclusion rule.
The card¶
Ship a card.yaml with your package so the method is loadable by card; the controller field
points at your class.
Or: skip the class entirely
A controller is not required. Point a slot at code instead of a {kind: …} label — a dotted
module.Class, or a .py file relative to the card — and the card is the method:
components:
population: galapagos.components.InMemoryPopulation
selection_policy: galapagos.components.ExploreExploitPolicy
prompt_builder: galapagos.components.DefaultPromptBuilder
proposer: ./my_proposer.py # your file, next to this card
evaluator: {kind: task} # always the task's
memory: {kind: none}
# no `controller:`
Six slots and a YAML file, no boilerplate. Write a controller when the loop needs to differ
(a scaffold that places an agent, drives its own container, or wires components to each other);
write specs when only the components do. The submission gate loads every
spec, so a typo fails CI rather than at run time. (Bundled scaffolds keep theirs next to the code, at
src/galapagos/scaffolds/<name>/card.yaml; for your own package any path works — you load it
with gx.load_scaffold(path="my_scaffold/card.yaml", model=...) (pass the model explicitly: a
third-party card's model.default is not resolved outside the bundled catalog).)
# my_scaffold/card.yaml
name: banditevolve # MUST equal the directory name
display_name: BanditEvolve
type: test_time_search
family: evolutionary_method # evolutionary_method | search_baseline | single_agent_baseline
summary: "UCB-routed island evolution with a meta-scratchpad."
source: "your repo / paper"
license: Apache-2.0
controller: my_scaffold.scaffold.BanditEvolveScaffold
# `description` is generated from this README's `## Overview`, and `status` from `controller`
# (stable iff one is wired) — do not write either by hand.
components:
population: {kind: island}
selection_policy: {kind: ucb_bandit}
prompt_builder: {kind: default_with_memory}
proposer: {kind: diff}
evaluator: {kind: task}
memory: {kind: scratchpad}
model:
default: openai/gpt-5.5
host: openrouter
roles: [propose]
requirements: {gpu: none, docker: optional, python: ">=3.10"}
defaults_config: config.yaml
The config¶
Ship a config.yaml with the defaults your build_components reads. It lives next to the code
(the bundled scaffolds keep theirs at src/galapagos/scaffolds/<name>/config.yaml); when bundled it
loads via GalapagosConfig.from_config(scaffold_name="banditevolve"), otherwise via path=:
# my_scaffold/config.yaml
seed: 0
general:
max_iterations: 100
population:
num_islands: 4
migration_interval: 20
selection_policy:
num_inspirations: 2
GalapagosConfig is typed: unknown sections or keys raise during load. Use the existing sections
(general, population, selection_policy, prompt_builder, proposer, evaluator, memory,
meta) or keep method-specific constants in your scaffold code.
Run it¶
import my_scaffold.scaffold # the @register_scaffold decorator fires on import
import galapagos as gx
scaffold = gx.GalapagosScaffold.from_card(
"banditevolve",
config=gx.GalapagosConfig.from_config(path="my_scaffold/config.yaml"),
model=gx.load_model("openai/gpt-4o-mini", host="openrouter"),
)
result = scaffold.run(task=gx.load_task("function_minimization"), max_iterations=40)
print(result.summary["scaffold"]) # -> "banditevolve"
gx.available_scaffolds() and galapagos scaffold list show the cards bundled in the galapagos
package (under src/galapagos/scaffolds/); a third-party scaffold loads by explicit path
(gx.load_scaffold(path="my_scaffold/card.yaml", model=...)) or, once its module is imported, by
its @register_scaffold name. To submit it to the Hub, see
Submit a scaffold — a full scaffold-repo walkthrough (card layout,
galapagos submit --repo-type scaffold, --dry-run preview) — and the general
Submit to the Hub.