Skip to content

Goal mode ​

A goal is a testable outcome you attach to the agent — a condition plus a verifier that ground-truths whether it's met (a shell command's exit code, a test run, a CI status, a data assertion, a plugin check, or an LLM judgment as the fallback). Goals turn "please do X" into "keep going / watch until X is provably true."

A goal is agent-driven: the agent's own turns do the work. After each turn the verifier runs; if not met, the agent is re-invoked with a continuation prompt until it passes, the iteration budget is spent, or it's flagged unachievable. Use for "make the tests pass," "finish the README."

Watching a metric someone else moves (a background engine, a training run, a deploy — "treasury ≥ 1,000,000," "rollout reaches 100%") is a watch, not a goal (ADR 0067): it's checked out-of-band on a cadence, never re-invokes the agent, and you can hold many at once. Create one with sdk.create_watch(...), POST /api/watches, or the agent's create_watch tool. (Goals used to carry a monitor disposition; ADR 0067 split it into its own primitive.)

When a goal reaches a terminal state it broadcasts on the event bus (goal.achieved / goal.failed, ADR 0039) — so the console, or any plugin, can react without writing code (see Reacting to a goal).

Goal mode is always on — there's no enable/disable toggle. The machinery stays dormant (and the set_goal tool a no-op gate) until you actually set a goal, so it costs nothing when unused. The tuning knobs (goal.max_iterations, goal.eval_model) live in Settings ▸ Agent.

It's modelled on protocli's goal system but deliberately more rigorous for a long-running server agent:

protocliprotoAgent goal mode
Completion checksmall-LLM judgmentpluggable verifier (command / test / CI / data), LLM only as fallback
Drive-to-donecontinuation promptcontinuation prompt + a persisted plan (the update_goal_plan tool)
Give-up pathuser sets "stop after N" in the textiteration budget + no-progress streak + the abandon_goal tool
Statein-memory, per sessiondisk-persisted per session (survives restart/reload)

Set one (the short path) ​

Tell the agent in chat — "goal: make the tests pass, verify with pytest -q" — or call set_goal with a condition and a verifier. The agent then keeps taking turns until the verifier passes, the iteration budget runs out, or it reports the goal unachievable. Watch progress in the console's goal panel (Manage from the console).

The two things worth getting right are the condition (testable, not vague) and the verifier (a real check — see Verifier types; an LLM judge is the fallback, not the default). The rest of this page is the mechanism behind that.

How it works ​

  1. You set a goal for a session (/goal …). Nothing else changes — the next message runs normally.
  2. When the agent produces a final answer (no more tool calls), the controller runs the goal's verifier.
  3. Met → the goal is marked achieved and the run ends.
  4. Not met → the agent records its running plan with the update_goal_plan tool, then the controller re-invokes it on the same thread (history preserved) with a continuation prompt that includes the verifier's reason + evidence and the current plan.
  5. This repeats until met, the iteration budget (goal.max_iterations) is spent (exhausted), the verifier returns the same evidence too many times (goal.no_progress_limit → unachievable), or the agent itself calls the abandon_goal tool with a reason (unachievable).

The loop wraps graph invocation in server/goal_loop.py, shared by both turn drivers (the A2A streaming path in server/chat.py and the non-streaming chat path in server/turn_sync.py); the graph itself is unchanged.

Yield instead of spin (ADR 0079). If the agent's next step waits on async or delegated work — a build, a peer agent, CI, a review — it doesn't have to burn iterations polling. It hands off to a watch or a schedule and ends the turn; the drive pauses (the goal stays active, iterations untouched) and resumes automatically when the trigger fires (⏸ goal paused — handed off to a watch/schedule). This is what lets a long, delegated goal span time instead of exhausting its budget waiting. Goals, tasks, watches, and schedules compose into one OODA loop over the agent's durable working-state — see ADR 0079.

Setting a goal ​

Send a control message through any channel (A2A, the React console chat, OpenAI-compat):

  • Fuzzy goal (LLM-verified):

    /goal the README documents every config block
  • Testable goal (JSON spec) — from a chat message you can use the declarative verifiers (plugin, or data with a contains substring):

    /goal {"condition": "migration recorded", "verifier": {"type": "data", "path": "state.json", "contains": "migration complete"}}

    Shell/eval verifiers are operator-only. command, test, ci, and data+expr execute on the host or hit a restricted-eval sink, so they are refused from a /goal chat message (a federation peer / API client shares the operator bearer today, #1407). A dedicated operator set-channel is the Phase 2 plan. (To watch a metric an external process moves — "treasury ≥ 1,000,000", "rollout reaches 100%" — use a watch (ADR 0067), not a goal: POST /api/watches or the create_watch tool. Watches poll out-of-band, react via run_in_session/hooks, support deadline/stall_after, and you can hold many at once.)

  • Per-goal patience: add "no_progress_limit": N to widen/narrow one goal's no-progress tolerance without changing the global default.

  • Status: /goal

  • Clear: /goal clear (aliases: stop, off, cancel, reset, none)

In the React console, typing / in the chat composer opens a command autocomplete (served from GET /api/chat/commands) so /goal is discoverable; ↑/↓ to pick, Enter/Tab to insert.

Programmatic status/clear is also available: GET /api/goals/{session_id} and DELETE /api/goals/{session_id}.

Manage from the console ​

The React console's Goals surface (right sidebar, in the Work hub) lists every session's goal — its condition, status (active / achieved / exhausted / unachievable), the verifier type, the iteration count, and the latest verifier reason. When a goal finishes, the console shows a toast (goal.achieved → success, goal.failed → error), driven by the bus events below.

Create — the panel's New goal action (and the Work overview's + Goal quick-add) open a guided wizard: the condition, a type-aware verifier picker, and an optional completion contract (ADR 0073). A goal created here opens a dedicated, focused chat tab and drives in it, so the whole loop streams live; a goal set in chat with /goal stays in that chat. Command/test/ci/data verifiers are allowed here — the trust gate that refuses them from a raw /goal chat message doesn't apply to the authenticated, operator-tier /api/goals path (ADR 0066).

Inspect — click a row to open the detail drawer: the agent's live plan (.plan.md, rendered as markdown), the completion-contract read-back, the last verifier reason/evidence, and a per-iteration timeline (ADR 0079).

Steer — from the drawer, give an active goal more room (Add iterations) or Restart a terminal one (re-arm + resume). Closing a goal's chat tab prompts you: keep it running in the background (detach), or stop it — which clears the goal and closes the tasks it filed (its session-scoped backlog, ADR 0079).

Backed by:

MethodPathPurpose
GET/api/goalsList all goals across sessions ({goals, enabled})
GET/api/goals/{session_id}One goal's detail + its plan artifact
POST/api/goalsSet a goal (operator-tier — any verifier). kick:false drives it from a chat tab instead of a headless turn
POST/api/goals/{session_id}/rearmExtend an active goal's budget, or restart a terminal one
POST/api/goals/{session_id}/resumeKeep an active goal running headlessly (detach on tab close)
DELETE/api/goals/{session_id}Clear (stop) one ({cleared, tasks_closed}); ?close_tasks=true also closes its task backlog

The plugin-verifier-only safe set (GoalController.set_goal_safe, ADR 0028 D3 — reached by the agent's set_goal tool, not importable as sdk.set_goal_safe) is a separate, programmatic path for agents/plugins — distinct from the operator /api/goals route above. A plugin drives goals through sdk.start_goal_loop.

Reacting to a goal ​

A terminal goal is a trigger, not just a checkbox. Every finish publishes one of two events on the event bus (ADR 0039):

TopicWhenPayload
goal.achievedverifier passed{session_id, condition, status, reason, evidence, mode}
goal.failedexhausted / unachievablesame shape

Two ways to react:

  • No code (any plugin / the console). Subscribe to the topic — registry.on("goal.achieved", …) in a plugin, or protoagent:subscribe from a sandboxed view. The built-in console toast is exactly this. Because it's the bus, nobody imports the goal system to listen.
  • Plugin code (richer). register_goal_hook(on_achieved=…, on_failed=…) hands your plugin the terminal GoalState to run arbitrary logic — set the next goal (phase progression), prompt the agent with a follow-up turn (sdk.run_in_session, below), stop a background engine, alert. This is how a plugin drives an autonomous loop: a terminal goal fires the hook → set the next goal, or prompt the agent. (To watch an external metric on a cadence, use a watch instead — ADR 0067.)

Goal fires → run a follow-up agent turn ​

To have the agent act when a goal fires — not just record a status — call sdk.run_in_session(session_id, prompt) from a hook. It enqueues the prompt as a one-shot agent turn in the goal's own session (that session's memory + full tools), runs it on the normal scheduler fire path, and returns immediately — so it's safe to call from a hook without blocking:

python
from graph import sdk

def register(registry):
    async def on_achieved(goal):                # terminal GoalState
        sdk.run_in_session(
            goal.session_id,
            f"The goal '{goal.condition}' just completed. Evidence: {goal.last_evidence}. "
            f"Summarize the outcome and start the follow-up work.",
        )
    registry.register_goal_hook(on_achieved=on_achieved)

Pass job_id= to make the re-arm idempotent, or delay_seconds= to defer the turn. This is the reaction half of the self-improving loop: a recurring cadence drives the work (sdk.start_goal_loop arms one against a watch-verified target — ADR 0067/#2060); a hook + run_in_session reacts when it lands.

Verifier types ​

Set via verifier.type in the JSON spec:

TypeSpec keysMet when
commandcommand, cwd?, timeout?the shell command exits 0
testsame as commandexits 0 (the runner's summary line is surfaced in the reason)
cipr or branchgh pr checks <pr> is all-green, or the latest run on branch concluded success
datapath + (contains or expr), workspace_relative?the file contains the substring, or expr (evaluated over parsed JSON as data) is truthy; the Goal/Watch forms and accepted chat specs set workspace_relative automatically
plugincheck (<plugin-id>:<name>) + args?the plugin-registered verifier returns met — see Plugins ▸ Goal & watch verifiers for register_goal_verifier and the (spec, ctx) contract (incl. ctx.invoker, the polling goal/watch's identity)
llm— (uses condition)a strict evaluator judges the transcript shows the goal demonstrably done (fuzzy fallback)

data expr runs in a restricted namespace — the parsed document is data, with only read-only builtins (len, any, all, sum, …). __import__, open, eval, etc. are unavailable.

Examples:

jsonc
{"type": "command", "command": "test -f /sandbox/out/report.pdf"}
{"type": "ci", "branch": "feat/my-branch"}
{"type": "data", "path": "/sandbox/state.json", "expr": "data['open_tickets'] == 0"}

The running plan (update_goal_plan) ​

Continuation prompts ask the agent to keep a running plan and record it each turn by calling the update_goal_plan tool. The controller persists that plan to a durable plan artifact for every goal and feeds it back into the next continuation — so the agent maintains a coherent plan across iterations instead of re-planning from scratch. (ADR 0079 unified this: the plan used to be written durably only for fresh_context goals, so a default same-session goal maintained a plan that read_plan() never saw.) The plan is injected back each turn as part of the agent's <working_state> block, and it doubles as the orient signal in the fleet trace export: a goal that maintains a real plan emits loop_shape=ooda training rows; a goal with no plan is labelled react. To stop early when the goal is impossible or out of scope, the agent calls abandon_goal with a reason (honoured only after the verifier runs, so a goal the world already satisfies still finishes achieved). Both tools are bound whenever goal mode is on and are harmless no-ops outside a goal.

Configuration ​

See the goal config block. Defaults: machinery enabled, max_iterations: 8, no_progress_limit: 3, verify_timeout: 120.

Security ​

command / test / ci verifiers execute on the server host with the agent's privileges. Setting a goal is an operator action — only accept goal specs from trusted callers. If you expose /goal to untrusted input, restrict it to data / llm verifiers or gate goal-setting behind auth.

Use a workspace-relative data-verifier path when the goal is agent-completed over untrusted /goal. A path such as report.md resolves under the same per-instance managed workspace used by the default read_file/write_file tools. Absolute paths and traversal (../x, including Windows-style forms) are refused on the untrusted chat channel. The forms mark their relative paths with workspace_relative: true; direct authenticated operator API specs retain the legacy server-CWD-relative default when that flag is omitted, plus absolute-path support for intentionally checking files outside the managed workspace.

Part of the protoLabs autonomous development studio.