Loops
Goal-directed agent runs with measured feedback, budget caps, and background execution.
A loop is a control structure that wraps an agent in a feedback-driven iteration. Instead of a one-shot prompt-and-answer, the loop runs the agent many times — each time feeding back a numeric score and optional detail — and halts when a stopping criterion fires (budget, iterations, agent convergence, or target score reached).
Design
A loop binds:
- An agent (
agent_id) — reuses the existing system prompt +tool_ids. - A feedback tool (
feedback_tool_id) — an optionaltoolsrow (kind='http') that takes the agent's candidate and returns{score, detail}. If no feedback tool is set, the loop still iterates but relies only on the agent callingloop_doneor a hard stop condition. - Stopping criteria —
max_iterations,budget_usd, optionaltarget_score, and the agent signaling convergence vialoop_done.
The runner:
- Initializes context with the loop's goal and an explanation of the protocol.
- Checks the budget gate (fail-closed: unreadable cost counts as spending the cap).
- Runs one agent turn through the existing tool loop (reusing the model → tools → model cycle from chat).
- Records the cost via
recordUsage(withcontext: 'loop'). - POSTs the candidate to the feedback tool (if configured), reads
{score, detail}, appends the detail to the dialogue, and updatesbest_score/best_output. - Checks for convergence, target reached, or budget/iteration limits.
- Repeats until a stop condition fires, then finalizes the
loop_runsrow and writes anactivity_logentry.
Background execution
The loop edge function (supabase/functions/loop/index.ts) runs in the background
using EdgeRuntime.waitUntil. The request returns immediately with a 202 Accepted
response and the run ID, while the loop executes as a background task. This avoids
hitting the 150s request idle timeout on the free tier (or 400s on paid).
Wall-clock gate
The loop internally tracks elapsed wall-clock time and self-stops before the platform
kills the worker (default: 130s out of 150s free / 400s paid, overridable via
LOOP_MAX_WALL_MS). When the time limit is reached, the loop finalizes gracefully with
stop_reason='time' and records the best result so far.
Worker shutdown hook
An addEventListener('beforeunload') handler keeps track of active runs and finalizes
any still in flight when the worker is retired early. This is a best-effort fallback;
the in-loop wall-clock self-stop is the primary guarantee.
UI integration
The Loops page (LoopsPage) lists loops and shows a detail view for each. A Run
button starts a new run, which streams progress live (iteration, score, cost meter).
Users can close the page and check back; Realtime subscriptions keep the transcript in
sync if they return.
A cost meter (progress bar) visualizes spending against the budget cap, turning red as it approaches the limit.
Stop reasons
The loop_runs.stop_reason enum includes:
budget— spending hit thebudget_usdcap.max_iterations— reached the iteration limit.converged— agent calledloop_done.target_reached— best score ≥target_score.time— wall-clock limit reached (background execution timeout).error— an exception occurred.
Built-in tools
loop_done — a no-op tool seeded as is_builtin. The agent calls it to signal
that further refinement is unlikely. The runner detects this call and stops with
converged.
Loop management tools (start_loop, check_loop, list_loops) — seeded as
is_builtin and dispatched by _shared/builtins.ts so all three agent loops (chat,
scheduler, webhook) get them. An admin can toggle them on ToolsPage, they respect
per-agent tool_ids scoping, and the webhooks.allow_tools gate still applies.
start_loop is exfiltration-adjacent (model budget spend) so admins can deactivate it
if they don't want the assistant kicking off loops.
Security & RLS
loops and loop_runs tables mirror the agents / tools RLS pattern:
loopsis readable by the owner, members whenvisibility='workspace', and admins.loop_runsinherits the parent loop's visibility.- The runner executes as the service role but re-enforces the caller's identity when calling the feedback tool (same discipline as shared builtins).
- Guardrails (pre-flight checks) screen the loop's goal before execution.
Usage tracking
Each model call in a loop records recordUsage with context: 'loop'. The /usage
page then breaks down spending by loop, like any other context.
Entry points
The loop runner (supabase/functions/loop/index.ts) accepts runs from three entry points:
- UI — a user clicks Run on the Loops dashboard; the browser holds a JWT with
sub = auth.uid(). - MCP server — an external Claude calls
run_loop; the request comes through the service-role key (nosub) and passes the acting user via thetriggered_byparameter. - Builtins — the in-app chat assistant calls
start_loop; same service-role context, sametriggered_bymechanism.
The function extracts the user ID from the JWT sub if present (it always wins), else falls back
to the explicit triggered_by parameter. This ensures a member cannot spoof another user: their
own JWT always takes precedence, and the service-role caller's explicit parameter is honored only
when there is no JWT sub.
Integration
- Scheduling: a
schedulesrow can target a loop vialoop_id, so loops can run on a cron tick (same pattern as scheduled agents). - Best-of-K: The schema reserves room for
runs > 1(multi-attempt loops), but v1 ships with single-run only (K=1). Multi-run is a natural follow-up.
Why this pattern
Grounding an agent in a closed feedback loop — where it gets real, measured signal and iterates — produces better results than a single pass. The price cap makes it safe to automate: "spend up to $5 trying to get this right, then stop" is a control non-engineers can understand. By reusing agents, the tool loop, and usage accounting, the loop layer is thin and composable.