Agent SDK integration
Why the Agent SDK, not the Messages API
Section titled “Why the Agent SDK, not the Messages API”The project’s core constraint: authenticate with a Claude Code OAuth
token (claude setup-token, subscription-based) instead of an API key.
That token is only honored by Claude Code itself — the plain Anthropic
Messages API rejects it. So the bot drives Claude the same way the official
GitHub Action does: through Claude Code, via the
Claude Agent SDK,
which spawns the bundled CLI as a subprocess.
What looks like a limitation buys a lot:
- Sessions for free — Claude Code persists conversations on disk;
resumegives every Discord thread durable memory. - Tools for free — WebSearch/WebFetch in chat mode; Read/Write/Bash in
agentic mode, with
allowedToolsas the safety boundary.
Every RunRequest carries a required claudeToken: the acting Discord
user’s own OAuth token (see claude/identity-store.ts). runner.ts sets
env.CLAUDE_CODE_OAUTH_TOKEN from it per call and deletes any stray
ANTHROPIC_API_KEY from the child env — there is no instance-wide credential
that could otherwise leak in.
How runner.ts shapes the call
Section titled “How runner.ts shapes the call”query({ prompt, // string, or AsyncIterable for image inputs options: { resume: claudeSessionId, // undefined on the first turn cwd, // per-thread workspace — NEVER changes model, systemPrompt, // custom string (chat) or claude_code preset + append (agentic) allowedTools, // ["WebSearch","WebFetch"] or + file/shell tools permissionMode, // "default" (chat) / "bypassPermissions" (agentic) maxTurns, // 10 chat / 40 agentic / 6 for /ask includePartialMessages: true,// stream_event deltas for pseudo-streaming abortController, // dashboard abort + /reset env, // process env + the acting user's own claudeToken (and, in // agentic mode, their linked githubToken if any) },})The message stream is consumed with a local structural type
(SdkMessageLike) rather than the SDK’s exported unions. This is deliberate:
the SDK is young and its types shift between minors. We depend on four
stable message shapes (system/init, stream_event, assistant,
result) and nothing else.
Session persistence — the sharp edge
Section titled “Session persistence — the sharp edge”Claude Code stores sessions under ~/.claude/projects/<hash-of-cwd>/.
Two consequences maintainers must protect:
- A thread’s
cwdis written once intothread_sessionsand must never be recomputed differently — otherwiseresumesilently starts fresh. - Deployments must persist
~/.claude(Docker volume), or restarts wipe all conversation memory.
A thread’s session is per-thread, not per-user: several Discord users can speak in the same thread and keep resuming the identical Claude session. This is intended, not a bug — but it means each turn is billed to whoever sent that message, and a file one user has Claude write into the thread’s scratch workspace is readable by the next person who speaks there. There is no per-user isolation within a single thread.
Upgrading the SDK
Section titled “Upgrading the SDK”The version is pinned exactly in packages/bot/package.json. To upgrade:
- Bump the pin,
npm install. npm run typecheck && npm test(tests mock the engine, so they catch interface drift in our code, not the SDK’s behavior).- Run a real smoke test with a token: start the bot, mention it, confirm
session resume works across two messages, check
stream_eventdeltas still arrive (watch the streamed reply grow while the bot shows “typing…”). - Note any changed error-message wording —
claude/errors.tsfixtures may need additions (add fixtures, don’t loosen patterns).
Known limitations
Section titled “Known limitations”- No headless token refresh: expired OAuth tokens require a human to run
claude setup-tokenagain. Surface, don’t hide, auth errors. - Rate-limit shapes are unstable: subscription limits arrive as free
text in the
resultmessage.errors.tsmatches defensively and falls back to a generic message. - A subprocess per run: concurrency is bounded by
RunQueue’s global semaphore; treatMAX_CONCURRENT_RUNSas a memory knob.