Scheduled Callbacks & Autonomous Agent Loops
SCORPIOX CODE can keep working after you stop typing. A scheduled callback is a timer the agent sets for itself: when the delay runs out, the timer's message lands in the conversation exactly like something you typed, and the agent loop wakes up and starts a fresh turn — no keystroke required. That one mechanism is what turns a single request into an autonomous loop: poll a deployment until it goes green, re-check a flaky upload, babysit a long build, or grind through a self-directed work queue for hours.
You meet callbacks from two sides:
- The agent drives them through the built-in
SetCallbacktool — scheduling, listing, and cancelling its own timers as it works. - You watch and manage them with the
/callbackscommand and its popup, plus the[CB:n]status-bar indicator.
Docs for SCORPIOX CODE @ 77c49df.
The whole idea in one line:
SetCallbackstores a message plus a delay (1–3600 s) and a repeat count in one of eight timer slots; when a timer comes due and the agent is idle, its message re-enters the conversation as a new turn and the timer re-arms itself — which is how SCORPIOX CODE keeps a session running on its own.
What a callback actually is
Think of it as a self-addressed reminder. The agent writes down what to check next and when to check it, then goes idle. When the deadline arrives, the message is injected as a new user turn and the agent wakes up, does that unit of work, and — if the job is not finished — schedules the next reminder. Because the agent picks both the message and the cadence, it can chain steps indefinitely without a human in the loop.
| Property | Value |
|---|---|
| Active timers | Up to 8 at once (slot IDs 0–7). A ninth schedule attempt fails. |
| Delay per fire | 1–3600 seconds. |
| Message size | Up to 2048 characters. |
| Repeat count | How many times the timer fires before it retires itself. Default 20, -1 for forever. |
| Re-arming | After each fire the timer re-arms with the same delay, so it ticks on a steady cadence. |
| When it can fire | Only while the agent is idle and callbacks are not paused. |
Three properties are worth internalizing early:
- A fire is a real turn, not a notification. The message enters the same path a typed message takes, so it costs a normal request and the agent can use tools in response.
- Timers never interrupt a running turn. A deadline that arrives mid-run is held, not dropped — see How firing works.
- The message is the instruction. Whatever the timer carries is what the agent acts on, so it pays to make it self-contained.
The SetCallback tool
SetCallback is a built-in tool, enabled by default. It is exposed to the model like every other tool, and it does three things through one parameter: action.
| Parameter | Type | Required | Description |
|---|---|---|---|
action | string | yes | set, list, or cancel. Omitted means set. |
message | string | for set | The text delivered back into the conversation when the timer fires. |
delay_seconds | integer | for set | Seconds to wait before firing. Must be between 1 and 3600. |
repeat_count | integer | no | How many times to fire before the timer retires. Default 20; -1 fires forever. |
callback_id | integer | for cancel | Which slot to cancel. Omit to cancel all. |
set — schedule a timer
{
"action": "set",
"message": "Check the deploy job; report success or failure, then re-arm if still running.",
"delay_seconds": 30,
"repeat_count": 40
}
Success reads like this:
Callback scheduled: fires every 30s, repeats 40 times.
With repeat_count: -1:
Callback scheduled: fires every 30s, repeats infinitely.
Refusals are explicit, so the agent can correct itself:
| Situation | Message |
|---|---|
No message | Error: missing message parameter |
| Delay out of range | Error: delay_seconds must be between 1 and 3600 (got 7200) |
| All 8 slots taken | Error: all callback slots full (max 8) |
list — inspect active timers
{ "action": "list" }
Active callbacks (2/8):
[0] "Check the deploy job; report success or failure, then re-arm if still..." - fires in 0m12s (39 left)
[3] "Check disk usage on /tmp" - fires in 1m00s (infinite)
Each line carries the slot ID, a preview of the message, the time until the next fire, and how many fires remain. With nothing scheduled it returns No active callbacks.
cancel — stop timers
Cancel one slot by ID:
{ "action": "cancel", "callback_id": 0 }
Cancelled callback slot 0: "Check the deploy job; report success or failure, then r..."
Or clear everything by omitting the ID:
{ "action": "cancel" }
Cancelled 2 callbacks.
Bad IDs fail loudly rather than silently: Error: callback_id 5 out of range (max 7) for a slot that does not exist, and Error: callback slot 3 is not active for one that already fired out or was cancelled.
How firing works
The scheduler is deliberately boring, which is what makes it safe to lean on.
Fires happen when the loop is idle. The terminal UI checks the timer table only when no agent run is in flight. If a deadline arrives while the agent is still working, the fire is deferred about five seconds and you see [Callback] Deferred 5s — agent busy in the transcript. Nothing is lost; the timer simply waits for a free moment.
Overdue timers re-base instead of burst-firing. When an agent run finishes, every timer whose deadline passed during the run is pushed forward by one full delay. A 30-second timer scheduled mid-run does not fire instantly the moment the run ends — it fires a full 30 seconds later. The corrected countdown is written back, so anything watching the session sees the real next-fire time.
Repeats re-arm from the moment they fire. delay_seconds: 30 with repeat_count: 40 is 40 fires spaced 30 seconds apart, counted from each fire. When the count reaches zero the slot retires itself — the last fire still delivers its message.
The fired message is visible as a turn. You see [Callback] Timer fired in the transcript, then the message itself as the next user turn. The agent answers it like any other prompt.
Everything an agent run does still applies. Callback-driven runs poll usage live in the status bar (so a multi-hour auto-pilot round does not freeze the token figures) and handle context compaction the same way an interactive run does — a long self-running session compacts when it reaches the context threshold instead of dying on a context-limit error.
The /callbacks command
/callbacks is your control surface — the way to see and manage timers without asking the agent.
| Command | Effect |
|---|---|
/callbacks | Toggle the popup open or closed |
/callbacks on | Show the popup |
/callbacks off | Hide the popup |
/callbacks pause | Freeze every timer — nothing fires until you resume |
/callbacks resume | Unfreeze, and re-base overdue timers so nothing fires instantly |
The four sub-arguments are offered as you type, so you never have to memorize them.
The popup
The popup is a draggable overlay (grab its title bar to move it, click [X] to close). One line per active timer:
Callbacks (2 active)
[0] "Check the deploy job; report s..." 0m12s (38)
[3] "Check disk usage on /tmp" 1m00s (inf)
Each line shows the slot index, a truncated message preview, the time until the next fire, and the fires remaining (inf when the timer repeats forever). Empty table reads No active callbacks. Pausing changes the title to Callbacks (2 active) PAUSED.
Pausing is not cancelling.
pausefreezes the firing clock andresumestarts it again with the schedule intact; onlycancelremoves a timer. Pause is session-local — it lives in the running process, so it does not survive a restart.
The status bar
Even with the popup closed, the status bar shows a compact live indicator whenever timers are active: [CB:2 2:14] means two active callbacks, the next one due in 2 minutes 14 seconds. The same numbers appear in the session's stats.json under a callbacks block (active_count, next_fire_ms, remaining_s), which is what scripts and dashboards should read — see Token Usage Observability.
Where timers live
Timer state is stored in the session folder — .scorpiox/sessions/<session>/callbacks.json — as a small JSON document listing every slot with its message, delay, repeat count, next-fire time, and a revision counter that bumps on every change. Three consequences fall out of that:
- Timers belong to the session. Resume that session and its timers come back with it; any that came due while you were away fire as soon as the loop is idle. Start a brand-new session and you start with an empty table.
- Other processes can drive it. Anything that can read and write that file — a remote bot API, a host application, your own script — can list, schedule, edit, or cancel timers, and the running agent picks the changes up between turns. A timer scheduled remotely fires even though no tool call ever made it.
- Worktrees stay consistent. Inside a git worktree the file is dual-written: once under the worktree's session folder and once under the main repository's, so tooling watching the main repo still sees the schedule.
The embedded-engine form factor (host applications that load the agent as a library) uses the same file, checked once a second by a status thread; a fired timer's message is queued through the exact path a host-supplied message takes.
Recipes
Poll a deployment without burning tokens on a tight loop. Have the agent start the deploy, then schedule delay_seconds: 120, repeat_count: 30 with a message like "Check the deploy status; if it succeeded or failed, cancel this callback and report; otherwise say nothing new." The agent checks once every two minutes instead of hammering the endpoint.
Watchdog a long build. delay_seconds: 600, repeat_count: -1 with "Confirm the build is still progressing; if it stalled, diagnose and cancel this callback." Cancel it when the build is done, or let it run and check the popup.
Auto-pilot a work queue. One timer, delay_seconds: 60, repeat_count: -1, message "Pick the next item from TODO.md, do it, mark it done. If nothing is left, cancel this callback." The agent works one item per round and shuts itself down when the queue empties.
A single-shot nudge. repeat_count: 1 turns any timer into a one-shot: fire once, deliver the message, retire. Useful for "remind me to rerun the tests in 20 minutes."
Related: when the opt-in loop-detection guard is enabled (LOOP_DETECTION=1) and the agent repeats an identical command, the warning it receives explicitly points at SetCallback as the preferred alternative — schedule a check-back instead of polling in a tight loop.
Gotchas
- Eight slots is a hard limit. Scheduling a ninth fails with
all callback slots full (max 8). For high-frequency work, run fewer longer-lived timers rather than many overlapping ones — and cancel what you no longer need (/callbacksshows which slots are idle). - The delay is capped at one hour. For longer waits, fire a short timer and have the agent re-schedule the next hop. That also gives it a chance to look at state between hops instead of sleeping blind.
repeat_countcounts fires, not seconds. Default 20.-1is forever;0or1is effectively one shot. A timer retires itself when the count runs out — its final fire still delivers.- Timers fire only while idle. Never mid-turn. A deadline that lands during a run is deferred (about five seconds) or re-based to one full delay after the run finishes, so you never get a stampede of instant fires.
- The message is the whole instruction. The agent acts on it exactly as if you had typed it. Write what to check, what to do in each outcome, and when to cancel — otherwise a repeating timer happily checks nothing forever.
/cleardoes not cancel timers. Clearing the conversation does not touch the timer table; cancel explicitly (or use/callbacks pausewhile you reorganize).- Pause does not survive a restart. It is a switch on the running session. Cancelled and fired timers, however, are recorded in the session file, so what you see after resuming is what was left running.
- Cancelling by ID needs the right slot. Slot IDs come from
SetCallbackwithaction: "list"or from the popup's[n]prefix. Cancelling without an ID clears every timer, which is rarely what you want mid-loop. - Nothing fires in the browser build. The WASM/browser target has no timer slots;
SetCallbackreportscallback slots not availablethere. Native and embedded runs are unaffected.
Related
- Token Usage Observability — the
[CB:n]status-bar indicator and thecallbacksblock instats.json. - Using the /keepalive Command — the other idle-time timer: it pings to keep the prompt cache warm rather than to wake the agent.
- Conversation Compaction — how a long auto-pilot loop survives its own context growth.
- Configuration and Profiles — where
TOOL_SETCALLBACKsits in the cascade and how to disable the tool per project or per profile.