Skip to content

Add createCallback: an awakeable URL the agent can hand off and await - #38

Merged
igalshilman merged 1 commit into
mainfrom
callback-tool
Oct 8, 2026
Merged

igalshilman merged 1 commit into
mainfrom
callback-tool

Conversation

@igalshilman

@igalshilman igalshilman commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds createCallback, a pending tool that works like a generic humanApproval. The agent gets a URL to hand off: to another agent, to a remote job such as Claude Code running elsewhere, or as an argument to another tool. Later it receives whatever is POSTed to that URL, unless the timeout passes first.

  • Creating the callback makes a Restate awakeable and returns at once:
    {"pending": true, "operationId": "...", "status": "waiting", "purpose": "...",
     "callbackId": "sign_1...",
     "resolveUrl": "{CALLBACK_BASE_URL}/restate/awakeables/{id}/resolve",
     "rejectUrl":  "{CALLBACK_BASE_URL}/restate/awakeables/{id}/reject",
     "timeoutSeconds": 3600}
  • Waiting races the awakeable against a durable timer. The model picks the timeout, from 1s to 24h. The body that was POSTed becomes the result (binary serde, decoded as text). A POST to rejectUrl, or the timeout passing, arrives as a failure. cancelOperation cancels the callback like any other pending operation.
  • Restate's ingress completes the awakeable itself, so none of our handlers sits on the callback path. A callback that arrives after a timeout, a cancel or the end of the turn is dropped.
  • CALLBACK_BASE_URL sets the ingress address that callers reach. It defaults to http://localhost:8080.

Example flow

The user asks the agent to have a remote Claude Code session fix a failing build and report back, waiting at most one hour.

user ──> "Fix the failing build on the remote box; tell me when it's done (max 1h)."

step 1  model ──> createCallback({purpose: "remote build fix", timeoutSeconds: 3600})
        run(): restate.awakeable() ──> {pending: true, operationId: "call_1",
                                         callbackId: "sign_1abc…",
                                         resolveUrl: "https://restate.example/restate/awakeables/sign_1abc…/resolve",
                                         rejectUrl:  "https://restate.example/restate/awakeables/sign_1abc…/reject"}
        complete() starts as a pending task: race(awakeable, sleep 3600s)

step 2  model ──> executeCommand("claude -p 'Fix the build. When done, run:
                    curl -X POST <resolveUrl> -d \"$SUMMARY\"
                  On failure, POST the reason to <rejectUrl>.' &")
        model ──> "I've started a remote Claude Code session and will report back when it calls in."

        … turn is parked on pending work: steering still works, no process is held
          (the invocation suspends until the awakeable or the timer completes) …

remote  Claude Code ──> POST https://restate.example/restate/awakeables/sign_1abc…/resolve
                        body: {"status":"fixed","commits":["a1b2c3"],"summary":"pinned zod to 4.1"}
        Restate ingress completes the awakeable ──> the turn resumes

step 3  runtime ──> "[Runtime event] Pending tool createCallback (call_1) completed successfully.
                     <untrusted-tool-output>{"ok":true,"result":"{\"status\":\"fixed\",…}"}</untrusted-tool-output>"
        model ──> "The build is fixed: zod pinned to 4.1 in a1b2c3."

Other endings of the same flow:

  • Rejected: the worker POSTs "tests still red" to rejectUrl. The model is told that the callback for "remote build fix" reported a failure: tests still red.
  • Timed out: nothing arrives within 3600s, so the timer wins. The model is told that no callback for "remote build fix" arrived within 3600 seconds. A late POST finds nobody waiting.
  • Cancelled: the user says "never mind", and the model calls cancelOperation({operationId: "call_1"}).
  • Another agent: the model passes resolveUrl to a sub-agent with messageSubAgent. The sub-agent does the work and POSTs its result with curl from its sandbox.

Design notes

  • A small exception to the tool API rule. An awakeable cannot be looked up by its ID, so the tool leaves its future in a new turn-local context.callbacks map for the waiting half. This is replay-safe because both halves run in the same doTurn invocation and the half that creates the awakeable always replays first. The exception is documented in tools-api.ts and the skill reference.
  • Signals were considered. The alternative was an Agent resolveCallback handler that signals the turn, as humanApproval does. Awakeables won because they give one opaque URL that accepts any body, with no public handler and no extra Agent state.
  • No resolveCallback tool. If the model passed a malformed ID to resolveAwakeable, the SDK would abort and retry the attempt forever. Other agents complete a callback by calling its URL instead.
  • Not for use inside executeProgram. A pending call inside a program completes inline, so the program would never see the URL. The program tool's description says to create the callback with a direct call.

Test plan

  • pnpm lint, pnpm build
  • core tests: 155 pass. New test/callback.test.mjs covers success plus replay, a rejected callback, and a timeout.
  • web tests: 26 pass
  • Manual check against a real restate-server (not done before merge): create a callback, then curl -X POST <resolveUrl> -d '{"status":"done"}'

🤖 Generated with Claude Code

createCallback is a pending tool, a generic form of humanApproval. run
creates a Restate awakeable and returns its ingress resolve/reject URLs,
which the model hands to another agent, a remote job or a tool. complete
races the awakeable against a durable timeout (1s to 24h); the posted
body becomes the result, and a reject or timeout a failure.

Restate's ingress completes the awakeable itself, so no handler of ours
sits on the callback path. An awakeable cannot be looked up by ID, so run
leaves its future in a turn-local context.callbacks map for complete;
both phases run in the same turn invocation and run always replays first.

CALLBACK_BASE_URL sets the ingress as callers reach it (default
http://localhost:8080). Programs are told not to create callbacks, since
a nested pending call completes inline and would hide the URL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@igalshilman
igalshilman merged commit 95a56fd into main Oct 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant