Skip to content

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.Class path (e.g. "galapagos.components.UCBBanditPolicy"), or
  • a .py file 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.