@titan-design/agent
v0.4.1
Published
Headless agent triggering over the Claude Agent SDK with env-scrub, failure taxonomy, and hard budgets
Readme
@titan-design/agent
Run one headless Claude Code session and get back either a typed answer or a
typed failure. A thin wrapper over the Claude Agent SDK's query() that adds the
three things a production caller always has to add itself: a scrubbed child
environment, mandatory circuit breakers, and a failure taxonomy you can branch on.
Tier 1 of the titan-platform DAG (TP-11). Depends on @titan-design/agent-protocol,
@titan-design/agent-lifecycle, and @anthropic-ai/claude-agent-sdk; zod is a peer (v4).
Run an agent
import { z } from "zod";
import { runAgent } from "@titan-design/agent";
const result = await runAgent({
prompt: "Summarise the failing tests in this repo.",
cwd: "/path/to/worktree",
maxTurns: 12,
maxBudgetUsd: 2,
outputSchema: z.object({ failing: z.array(z.string()), likelyCause: z.string() }),
});
if (result.ok) {
console.log(result.output.likelyCause, result.usage.totalCostUsd);
} else if (result.failure.kind === "rate_limited") {
scheduleRetry(result.failure.retryAt);
}runAgent never throws for anything the agent does. The only throws are caller
mistakes caught before the session starts: a missing or non-positive budget.
Budgets are required, not defaulted
The SDK leaves maxTurns and maxBudgetUsd unlimited. Rather than pick a
default nobody would notice was wrong, both are required fields on
AgentRunConfig and runAgent throws a TypeError before it calls query()
if either is missing, zero, negative, or not finite.
The environment scrub
prepareEnv(env, { allowApiKeyBilling }) copies the environment and hands the
result to options.env. It never mutates its argument and never reads
process.env except as the default first argument.
| Variable | Action | Why |
|---|---|---|
| CLAUDECODE, CLAUDE_CODE_SSE_PORT, CLAUDE_CODE_ENTRYPOINT | strip | a child CLI that inherits these refuses to start inside a parent session |
| every other CLAUDE_CODE_* (e.g. CLAUDE_CODE_EXECPATH) | strip | describes the parent session and confuses the child's config inference |
| CLAUDE_CODE_OAUTH_TOKEN | keep | the subscription credential |
| CLAUDE_CODE_USE_BEDROCK / _VERTEX / _FOUNDRY | keep | a deliberate provider choice |
| CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS | keep | a deliberate opt-out |
| CLAUDE_CODE_MAX_RETRIES | keep, default 3 | the built-in default of 10 turns an outage into a long silent hang |
| ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN | strip unless allowApiKeyBilling | either one silently outranks the OAuth token and bills the API account |
| HTTP_PROXY, HTTPS_PROXY, ALL_PROXY (both cases) | strip | leak from the parent shell and re-route the child's API traffic |
| everything else | keep | ordinary process environment |
Two guards sit either side of the run. assertAuthEnvOk is the pre-flight: it
refuses to start when the OAuth token is missing, or when a metered credential
survived the scrub. The claude-print harness passes requireOAuthToken: false,
because the CLI's keychain login is its credential; the metered-credential check
still applies. The post-flight reads apiKeySource off the first
system/init message and ends the run as auth_misconfigured if it names the
env API-key path. That check is a blacklist, not a whitelist: the SDK's
ApiKeySource union grows over time, and an unknown new member is far more
likely to be another benign route than a billing one.
Failures
One kind per recovery strategy:
| kind | Means |
|---|---|
| rate_limited | usage or burst limit; carries retryAt when the stream or the message said when |
| budget_exceeded | maxBudgetUsd was reached |
| max_turns | maxTurns was reached |
| schema_invalid | the SDK exhausted its structured-output retries, or the answer failed the caller's zod schema |
| refusal | the model declined |
| auth_misconfigured | the pre-flight or the apiKeySource check failed |
| runtime_error | anything else, including a dead subprocess |
| aborted | the caller's AbortSignal fired |
| inactivity_timeout | no message arrived for inactivityTimeoutMs (default 600 000) |
classifyResult(resultMessage, options?) is exported so a product can map a
result message it captured itself.
Structured output
Pass outputSchema and the SDK is asked for outputFormat: { type: "json_schema" }.
The SDK validates and retries on its own; this package re-parses
structured_output with the same zod schema so the value you receive is the one
TypeScript promises. result.output is the final assistant text when no schema
was given.
Cost
result.usage is { totalCostUsd, modelUsage, turns, durationMs }. These are
the SDK's client-side estimates, priced from a bundled table. Use them as a
budget signal, never as a billing statement. The SDK's cost fields are already
cumulative across a query() call, so this package reads the latest result
rather than summing; modelUsage is the fallback when a crash result arrives
with the total zeroed.
Subagents and permissions
agents, mcpServers, hooks, allowedTools, disallowedTools, model,
resumeSessionId and settingSources pass straight through. So do tools,
the built-in tool set ([] runs with no tools, for a pure judgement call), and
systemPrompt, which replaces the Claude Code default prompt. Leaving either
unset keeps the SDK default. Two defaults are chosen for headless safety:
permissionModedefaults to"dontAsk", so nothing runs that was not pre-approved.settingSourcesdefaults to[], so no CLAUDE.md, settings file, or.mcp.jsonis picked up off the filesystem unless you ask for it.
Gotcha: a subagent inherits bypassPermissions, acceptEdits and auto
from the parent and cannot narrow them per-subagent. A parent running wide open
gives every subagent that same reach regardless of what its AgentDefinition
says. Prefer allowedTools wildcards over a permissive mode.
This package runs one session. It does not pool, schedule, or retry; that belongs to the workflow tier.
The claude-print harness
harness: "claude-print" makes runAgent spawn the claude CLI in print mode
instead of calling the SDK. Use it for one-turn structured answers, such as a
triage or judgement step, on a machine where the CLI is already logged in. The
CLI's own keychain login is the credential, so CLAUDE_CODE_OAUTH_TOKEN is not
required. harness defaults to "claude-code", and the SDK path is unchanged.
const result = await runAgent({
harness: "claude-print",
model: "sonnet",
prompt: "Is this diff a refactor? Answer as JSON.",
outputSchema: z.object({ refactor: z.boolean() }),
cwd: process.cwd(),
maxTurns: 1,
maxBudgetUsd: 0.5,
});| Aspect | claude-print behaviour |
|---|---|
| Binary | CLAUDE_BIN, else the first executable file named claude on the scrubbed env's PATH; never a shell function or alias |
| Fixed flags | -p --output-format json --tools "" --strict-mcp-config --mcp-config '{"mcpServers":{}}' --setting-sources "" --max-budget-usd <maxBudgetUsd> |
| maxTurns | --max-turns <maxTurns>, raised to at least 2 when outputSchema is set, because the CLI can need a second turn to emit the structured answer |
| Prompt | written to stdin, so large prompts avoid the argv size cap |
| model | --model <model> |
| systemPrompt | --system-prompt <text>, which replaces the default Claude Code prompt |
| outputSchema | --json-schema <schema> without the $schema header, which the CLI rejects; structured_output is then re-parsed with the zod schema and a mismatch is schema_invalid |
| inactivityTimeoutMs | a wall deadline, because JSON output arrives only at the end; SIGTERM to the process group, then SIGKILL after CLAUDE_PRINT_KILL_GRACE_MS |
| signal | kills the process group the same way and returns aborted |
| allowApiKeyBilling | same scrub as the SDK path: ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are stripped unless it is set |
| Result | the JSON result has the SDK result-message shape, so classifyResult and usageFromResult apply unchanged; onMessage receives that one message |
| init | only model is known, taken from modelUsage; the JSON output carries no init message, so there is no apiKeySource post-flight |
| Logged-out CLI | an error result that says to log in becomes auth_misconfigured |
| error_max_turns | runtime_error, which callers such as the workflow agentRunner treat as retryable; with no tools the cap is hit only on a structured-output retry, which a fresh call usually clears |
| Rejected options | tools (other than []), allowedTools, disallowedTools, permissionMode, resumeSessionId, agents, mcpServers, hooks and a non-empty settingSources throw a TypeError before anything spawns |
claudePrintCapabilities() reports the same limits in the shared capability
vocabulary: fresh runs, structured output, external cancellation and token
reporting are supported; resume, tools and every interactive capability are not.
There is no HarnessAdapter<"claude-print"> yet, so dispatchHarnessRun and the
durable dispatcher cannot use it; claude-print is selectable only through runAgent.
Without systemPrompt the default Claude Code prompt costs about 7,600 input
tokens per call. A short systemPrompt brings that down to about 1,000.
usage.input_tokens in the CLI's JSON result counts only the non-cached input
tokens. A live triage run saw input_tokens: 2 beside 2,100 to 37,000
cache_creation_input_tokens per call. modelUsage splits input the same way.
Read the cache fields, or use totalCostUsd, when judging what a call cost.
Explicit multi-harness contracts
dispatchHarnessRun(request, adapter) is the new, explicit contract for Claude
Code and Codex adapters. It does not replace or reroute runAgent(). Every request
has a discriminating harness, a fresh or resume target, and a required positive
finite wallTimeMs. Claude's Anthropic SDK fields live only under the
claude-code branch; Codex model, reasoning, sandbox, approval, and native JSON
Schema fields live only under the codex branch.
Optional limits name their unit (usd, model_requests, agent_iterations, or
tokens), scope (execution or conversation), and enforcement (hard or
advisory). These are not interchangeable: an agent iteration is not a model
request, and an estimated SDK dollar stop is not a hard financial cap.
Adapters supply a HarnessCapabilityDescriptor that marks every operation and
declared limit supported, unsupported, or unverified, with evidence or a
reason. Core declares no adapter capabilities itself. Before invoking an adapter,
the dispatcher checks the operation, resume identity, structured-output and
cancellation needs, caller requirements, mandatory hard execution deadline, and
all optional limits. Unsupported and unverified requirements return an
unsupported_requirement result with no adapter call.
Support for the hard milliseconds execution limit means the adapter can stop
the local execution at its deadline. It does not claim that a remote model request
has stopped unless the adapter separately reports verified cancellation support.
Normalized progress, results, usage measurements, execution identity, conversation
identity, and transcript source hints contain no harness-native event types.
Supervised Codex exec adapter
createCodexExecAdapter({ auth: "cached-cli" }) runs the pinned ChatGPT desktop
Codex binary through codex exec --json. The caller must select a model and an
absolute working directory. Fresh runs persist a native thread; resumes always use
the supplied native thread ID and never --last. The adapter ignores user config,
strips API-key credentials from the child environment, accepts only the
noninteractive never approval policy, and leaves persistence enabled so
@titan-design/session-read can discover the rollout by thread ID and namespace.
The adapter checks codex --version before every launch. The mandatory wall deadline
covers that check, temporary output-schema setup, and the run itself. Timeout or
caller abort terminates the owned process group, then sends SIGKILL after the
configured grace period. JSONL output becomes normalized progress, conversation
identity, final text or locally validated structured output, and token usage with
Codex event provenance. Hard dollar, request, iteration, and token caps remain
unsupported and fail preflight before either version probing or process spawn.
A zero exit is successful only after Codex emits turn.completed. Its usage is a
turn snapshot: the adapter uses Codex's native turn ID when one is present and an
execution-correlated synthetic turn ID otherwise. It is never labeled as a
conversation total. If an abort or wall deadline stops the OS process without a
native terminal turn event, the result is cancelled_unknown and retains the
requested cause and process exit evidence. Temporary schema cleanup completes
before the terminal progress event, so cleanup failure cannot follow a reported
successful finish.
Ending a run
Every run ends cleanly. The inactivity watchdog resets on each streamed message
and, like the caller's AbortSignal, ends the run through the SDK's
abortController and then query.close(), so no CLI subprocess is left behind.
Bounded Claude adapter
createClaudeCodeAdapter({ maxTurns, maxBudgetUsd, inactivityTimeoutMs?, deps? })
wraps the existing runAgent in the common HarnessAdapter<"claude-code">
contract. Both native circuit breakers remain required. Every request also supplies
wallTimeMs; expiration aborts and closes the owned SDK query. A local stop cannot
prove that native execution was cancelled, so the durable dispatcher records an
unknown cancellation unless terminal evidence is available.
Fresh and resumed requests keep invocation identity separate from native session
identity. Native options, permission settings, MCP servers, schema validation, and
cached-auth handling flow through runAgent. Structured output remains validated
by the caller's Zod schema. SDK query usage is aggregated across its main, subagent, and internal model calls
into one turn-scoped snapshot with estimated cost. Multiple models yield a null
model label so their totals do not overwrite each other. Total input includes cache
reads and cache creation; those subset counters must not be added again.
The adapter does not translate SDK maxTurns into a generic model-request or
agent-iteration limit, or treat the SDK budget estimate as an exact monetary cap.
Generic optional limits remain unsupported until their semantics are verified.
No process reattachment, visible terminal, or semantic-interrupt capability is
claimed by this wrapper. Legacy runAgent callers retain their existing API.
Durable harness dispatch
createDurableHarnessDispatcher(adapter, { ledger, supervisorId, leaseMs })
wraps either harness adapter with an early durable acknowledgment and a completion
promise. Apply executionLedgerMigration(version) from
@titan-design/agent-lifecycle to the authoritative database before constructing
the ledger. Each dispatch requires caller-generated executionId and requestKey
values. The dispatcher commits prepare and begin_dispatch before calling the
adapter, renews its captured owner lease while the call is live, and commits the
terminal result before resolving completion.
reconcile(executionId) returns a live handle only in the same dispatcher instance
while its exact owner generation and lease remain valid. After restart it can read
back a durable terminal result. An expired record that never committed
begin_dispatch is closed as a retryable failure because the ledger proves the
adapter was not called. A missing record or any post-dispatch record without an
attachment handle returns unknown or recovery-required evidence and never
authorizes resubmission. The dispatcher does not infer liveness from a PID or a
transcript.
Cancellation intent is stored before the local abort signal fires. Without a
native terminal acknowledgment after a deadline or abort, the ledger records
cancellation_unknown. Lease-renewal, stale-owner, and local persistence failures
settle completion promptly even when the adapter ignores abort; they cannot write a
terminal result through a newer owner's fence. Durable results must be JSON-safe.
