Build with the SDK
Use Agent AFK as a TypeScript library: drive the agent loop with query(), AgentSession, custom tools, subagents, and provider routing from your own code.
Embed AFK's agent loop in a TypeScript application when you need programmatic tools, structured responses, or a persistent conversation. Import the public API from agent-afk; the package includes JavaScript and type declarations, so you do not need a source checkout.
Install
Add the library
pnpm add agent-afk zodzod is used by the structured-output and custom-tool examples. The package root exports the public SDK; there is no public agent-afk/sdk subpath.
Configure your provider credential
Use your existing ANTHROPIC_API_KEY environment variable or pass apiKey from your application's secret store. Do not hardcode a credential in source.
Requires Node.js ^22.22.2 || ^24.15.0 || >=26.0.0. See the Quickstart for OAuth and alternative
credential paths, and Models & Providers for non-Anthropic
backends.
Two entry points
There are two ways to drive the loop. Pick by whether you need conversation state.
query() / queryText() / queryStructured() | new AgentSession(...) | |
|---|---|---|
| Shape | One-shot functions | Long-lived object |
| State | Fresh session per call, with optional resume | Holds history across turns |
| Lifecycle | Managed automatically (always closes) | You call close() |
| Use when | A single prompt-to-answer call | Multi-turn conversations, runtime control, hot model swaps |
Both accept the same options bag and route through the same provider layer.
One-shot queries
queryText: prompt in, string out
The simplest possible call. Returns the final assistant text as a Promise<string>:
import { queryText } from 'agent-afk';
const answer = await queryText('Summarize the last 10 git commits in this repo.', {
model: 'sonnet',
});
console.log(answer);If you omit model, the one-shot helpers default to sonnet. If you omit apiKey, the
Anthropic provider reads ANTHROPIC_API_KEY from the environment.
query: stream events as they arrive
query() returns an async generator of OutputEvents. Text arrives as chunk events; iterate
and print the content deltas as they stream:
import { query } from 'agent-afk';
for await (const event of query('Write a haiku about async generators.', { model: 'sonnet' })) {
if (event.type === 'chunk' && event.chunk.type === 'content') {
process.stdout.write(event.chunk.content);
}
}The streamed text lives at event.chunk.content on { type: 'chunk' } events whose
chunk.type === 'content'. This is the OutputEvent union (see the
event reference below), not the lower-level provider event
stream. Guard on both discriminants before reading content.
The generator closes its underlying session in finally, on normal completion, an early break, or a thrown error. Finish or explicitly close the iterator rather than abandoning it.
queryStructured: validated JSON out
Pass a Zod schema and get back a parsed, validated value of that type. AFK
extracts JSON from the response and, if it fails validation, re-prompts with the validation
error up to maxRetries times (default 2) before throwing:
import { queryStructured } from 'agent-afk';
import { z } from 'zod';
const Review = z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
score: z.number().min(0).max(1),
themes: z.array(z.string()),
});
const result = await queryStructured(
'Classify this review: "The battery dies in an hour but the screen is gorgeous."',
Review,
{ model: 'sonnet', maxRetries: 3 },
);
result.sentiment; // typed as 'positive' | 'neutral' | 'negative'Query options
Every one-shot helper takes the same options bag: it is the full AgentConfig
with model made optional. queryStructured additionally accepts maxRetries and
injectSchemaPrompt.
await queryText('...', {
model: 'opus', // Alias or full id; defaults to 'sonnet'
systemPrompt: 'You are a terse assistant.',
maxTurns: 20,
// Restrict execution with a permission callback, not config.tools.
canUseTool: async (name) => ['read_file', 'grep', 'glob'].includes(name)
? { behavior: 'allow' }
: { behavior: 'deny', message: 'Only repository reads are allowed.' },
maxBudgetUsd: 0.50,
});Stateful sessions
AgentSession holds conversation state across turns. The constructor is synchronous;
initialization runs internally and every send* call awaits it, so you can construct and
immediately send:
import { AgentSession } from 'agent-afk';
const session = new AgentSession({
model: 'sonnet', // required
systemPrompt: 'You are a pair-programming assistant.',
// Configure canUseTool if you need to restrict tool execution.
// apiKey is optional; falls back to ANTHROPIC_API_KEY
});model is required for AgentSession (there is no default, unlike the one-shot helpers).
Sending messages
Three ways to send a turn, mirroring the one-shot helpers:
// 1. Await the final message
const reply = await session.sendMessage('What does src/index.ts export?');
console.log(reply.content); // string
// 2. Stream the turn (same OutputEvent shape as query())
for await (const event of session.sendMessageStream('Now explain the provider layer.')) {
if (event.type === 'chunk' && event.chunk.type === 'content') {
process.stdout.write(event.chunk.content);
}
}
// 3. Validated structured output on the live turn
import { z } from 'zod';
const files = await session.sendMessageStructured(
'List the TypeScript files you touched, as JSON.',
z.object({ files: z.array(z.string()) }),
);
await session.close(); // release the session when doneConversation state accumulates across all three: turn 2 sees turn 1.
Runtime control
await session.interrupt(); // cancel the in-flight turn
await session.setModel('opus'); // hot-swap the model mid-conversation
await session.setPermissionMode('acceptEdits'); // change the permission posture
await session.close(); // tear down and free resourcesBudgets and cancellation
Cap spend with maxBudgetUsd. Once cumulative cost crosses the ceiling, AFK raises a
BudgetExceededError (exported from agent-afk; it carries runningCostUsd and
maxBudgetUsd). How it reaches you depends on how you drive the turn: sendMessage and
sendMessageStructured throw it, while the streaming paths, query() and
sendMessageStream(), surface it as an error event (event.type === 'error') instead of
throwing. Wire an AbortSignal to cancel externally:
const controller = new AbortController();
const session = new AgentSession({
model: 'sonnet',
maxBudgetUsd: 1.00,
abortSignal: controller.signal,
});
// elsewhere: controller.abort() cancels any in-flight workRestore a conversation from its journal
Pass the previous session's id as resume. When you have not supplied your own history, AgentSession loads the on-disk journal before the first provider request. Tool calls and tool results are restored alongside text.
import { AgentSession, queryText, loadJournalMessages } from 'agent-afk';
const first = new AgentSession({ model: 'sonnet' });
let savedId: string | undefined;
try {
await first.sendMessage('Remember that the project codename is Cedar.');
savedId = first.sessionId;
} finally {
// Finish journal writes before constructing a reader for the next session.
await first.close();
}
if (!savedId) throw new Error('The provider did not assign a session id.');
// Optional inspection through a helper exported from the package root.
const messages = loadJournalMessages(savedId);
console.log(`Stored messages: ${messages?.length ?? 0}`);
const answer = await queryText('What is the project codename?', {
model: 'sonnet',
resume: savedId,
});
console.log(answer);The same resume option works on a new AgentSession, query(), and queryStructured(). The journal normally lives under ~/.afk/state/sessions/; the resumed process must use the same runtime state location.
| Condition | Behavior |
|---|---|
Explicit resumeMessages or resumeHistory | Your supplied context wins, even if empty |
sessionId without resume | Names the session; does not load prior conversation |
persistSession: false | Disables this automatic disk restoration |
AFK_MESSAGE_JOURNAL_DISABLED=1 | Journal restoration is disabled |
| Missing or empty journal | Continues without automatically loaded context |
| Malformed journal that throws during hydration | Warns on stderr and continues without prior context |
| Forked child configuration | Uses its parent/fork context path, not this root-session auto-load |
SDK continue does not perform the CLI's latest-session lookup. Resolve the intended id in your application and pass resume. If missing history is unacceptable, check loadJournalMessages before submitting the next prompt and stop if it returns no usable history.
Custom tools
The tool() helper builds an in-process tool from a name, description, Zod input schema, and a
handler. The handler receives the parsed and validated input plus an AbortSignal, and
returns a result object:
import { tool, queryText } from 'agent-afk';
import { z } from 'zod';
const getWeather = tool(
'get_weather',
'Get the current temperature for a city.',
z.object({ city: z.string() }),
async ({ city }, _signal) => {
// Demonstration data. Replace with your application's lookup.
const temperatures: Record<string, number> = { Oslo: 8, Paris: 18 };
const tempC = temperatures[city];
return tempC === undefined
? { isError: true, content: `No temperature available for ${city}.` }
: { content: `It is ${tempC}°C in ${city}.` };
},
);
const answer = await queryText('What should I wear in Oslo today?', {
customTools: [getWeather],
});Return { content: string } on success, or { isError: true, content: string } to signal a
failure the model can react to. Input validation happens for you: the wrapper safe-parses and
returns a structured error rather than throwing on a bad payload.
Pass custom tools via customTools on either AgentConfig (for AgentSession) or the one-shot
options bag. They also work with a live session:
const session = new AgentSession({ model: 'sonnet', customTools: [getWeather] });Built-in tools win on name collision. A custom tool named bash or write_file cannot shadow
the built-in of the same name. Choose a distinct name. Custom tools are gated by the same
PreToolUse/PostToolUse permission checks as built-ins.
Subagents
Fork isolated child sessions programmatically with SubagentManager. Each child starts with
zero conversation context and returns a compressed result to the parent. Give the fork an
optional Zod outputSchema to get validated structured output back:
import { AgentSession, SubagentManager } from 'agent-afk';
import { z } from 'zod';
const parent = new AgentSession({ model: 'sonnet' });
const manager = new SubagentManager({ parentModel: 'sonnet' });
const handle = await manager.forkSubagent({
parent, // provides session identity to fork from
agentType: 'todo-scanner', // required display label
config: {
model: 'haiku', // right-size the child
},
outputSchema: z.object({ todos: z.array(z.string()) }),
phaseRole: 'read-only', // optional: hard read-only tool boundary
});
// Drive the child with its task prompt:
const result = await handle.runToResult('Find every TODO comment under src/ and list them.');
if (result.status === 'succeeded') {
console.log(result.output?.todos); // validated against outputSchema
}
await handle.teardown();
await parent.close();handle.run(prompt) returns the final Message; handle.runToResult(prompt) returns a
SubagentResult<T> with status, the validated output, the raw message, and any error or
schemaError. Passing phaseRole: 'read-only' enforces a read-only tool allowlist at the
dispatcher gate. This blocks repository mutation tools, not an operating-system sandbox.
For the higher-level agent tool, background dispatch, and the compose DAG that the CLI
exposes, see the Subagents & Delegation guide.
Subagents, skills and compose from the SDK
The agent, skill, and compose tools are opt-in for SDK sessions. A bare new AgentSession(config) exposes none of them; you build a bundle with createWiredExecutors and pass it as AgentConfig.executors.
Tool availability by surface
| Surface | agent | skill | compose |
|---|---|---|---|
| REPL, chat, Telegram, daemon, web | wired by surface | wired by surface | wired by surface |
SDK default (bare new AgentSession) | off | off | off |
SDK with executors | per toggle | per toggle | per toggle |
Surface wiring is not a guarantee of access: tool filters, permission modes, and nesting limits still apply. Pass opts to createWiredExecutors to control which tools the model sees. When opts is omitted, all three are enabled, but you must still provide config.hookRegistry. When opts is supplied, an omitted toggle means off:
import { createWiredExecutors, AgentSession } from 'agent-afk';
// Build one bundle per AgentSession.
const { executors } = createWiredExecutors(
{ model: 'sonnet' },
{
agent: true, // expose agent tool
skill: true, // enable discovered skills
compose: true, // expose compose tool
pluginConfigs: [], // no ~/.afk/plugins scan (the default)
unattended: true, // run without SubagentStart/Stop hooks
},
);
const session = new AgentSession({ model: 'sonnet', executors });
try {
const reply = await session.sendMessage('Summarize open TODOs under src/.');
console.log(reply.content);
} finally {
await session.close(); // drains the bundle; factory dispose() is idempotent too
}queryText, query, and queryStructured accept executors via their options. Build a fresh bundle per call because one bundle binds to exactly one session:
import { createWiredExecutors, queryText } from 'agent-afk';
const { executors } = createWiredExecutors(
{ model: 'sonnet' },
{ agent: true, skill: true, compose: true, unattended: true },
);
const answer = await queryText('List files changed in the last commit.', {
model: 'sonnet',
executors,
});
console.log(answer);
// session.close() is called automatically by queryText; executors drain with itHooks: fail-closed by default
createWiredExecutors refuses to build a bundle unless one of these conditions is met:
opts.hookRegistrysupplies an object implementing theHookRegistryinterface.config.hookRegistry(on theAgentConfigpassed as the first argument) is set.opts.unattended: trueis explicitly provided.
With unattended: true and no registry, the bundle runs without SubagentStart/SubagentStop hooks. Nothing is silently constructed. The fail-closed default prevents silent drops of audit hooks that a production caller expects to fire at every nesting depth.
Skill allowlist: exact-match semantics
Pass a string[] as opts.skill to enable the tool AND restrict it to the listed names. Each entry must match the skill name exactly as the model would call it:
- A bare name like
'search'authorizes onlysearch, notmy-plugin:search. - A plugin skill is requested by its namespaced name (
<plugin>:<skill>, e.g.'my-plugin:review') and must be listed verbatim; there is no prefix or suffix matching. - The allowlist applies at every nesting depth: a skill dispatched by another skill inherits the same gate.
pluginConfigs: default [] (no home-directory discovery)
Unlike CLI executor wiring, this factory does not scan ~/.afk/plugins or imported plugin roots by default. pluginConfigs defaults to []; pass explicit local plugin entries to opt in. This isolation rule concerns the executor bundle, not every other discovery mechanism in an embedded session.
Fields ignored on the SDK executor path
Two AgentConfig fields are ignored when building a bundle. Supply opts.warn to receive their warnings; its default is a no-op:
config.agents: named agents come from the built-in/user/project registry andpluginConfigs. Pass agent definitions throughpluginConfigs, not through the session config.config.mcpServers:- Executor path (SDK bundles via
createWiredExecutors): MCP tools require a caller-ownedconfig.mcpManager— pass a pre-built manager instance. The executor path does not construct one from the server list. - Root
AgentSession(direct use without executors):mcpServersis honoured; the session builds, connects, and owns the manager automatically. A caller-suppliedmcpManageris never disconnected by the SDK on session close. Failures surface inmcpServerStatus()/waitForInitialization(). SDK-built servers default to the trusted user-global layer (no project env-containment).
- Executor path (SDK bundles via
Budget: tree-wide, checked at child completion
With the wired executors, AgentConfig.maxBudgetUsd counts root turn cost plus recorded successful child completion cost against one ceiling. When that recorded total reaches the ceiling, the parent aborts with BudgetExceededError. A running child can overshoot before its completion is recorded. This is not a provider-side billing limit, and unknown or unreported cost cannot enforce a dollar cap.
Background jobs unavailable
Background subagent jobs are unavailable through this factory, so their cancellation, steering, and health tools are not exposed by its executor bundle. The bash tool's run_in_background: true flag is also unavailable on the sdk surface. Use foreground execution or manage long-lived processes outside the embedded session.
One bundle per session
A SessionExecutors bundle binds to exactly one AgentSession. Passing the same bundle to a second session (or a second queryText call) throws before any rewiring happens. Call createWiredExecutors once per session:
import { AgentSession, createWiredExecutors } from 'agent-afk';
// WRONG: reusing a bundle throws on the second bind
const { executors } = createWiredExecutors({ model: 'sonnet' }, { agent: true, unattended: true });
const s1 = new AgentSession({ model: 'sonnet', executors }); // ok
const s2 = new AgentSession({ model: 'sonnet', executors }); // throws
// RIGHT: fresh bundle per session
function makeSession() {
const { executors } = createWiredExecutors({ model: 'sonnet' }, { agent: true, unattended: true });
return new AgentSession({ model: 'sonnet', executors });
}Process-level environment caps
AFK_MAX_NESTING_DEPTH also applies to SDK-spawned subagents. Environment settings and model-slot bindings are process-global, so do not use conflicting global configurations for concurrent embedded sessions. The bundle uses the agent-layer credential resolver for child models; pass explicit session credentials only to the provider they belong to.
Providers and models
AFK routes each session to a provider based on the model id. Usually the model string is enough, but you can inspect routing:
import { providerForModel, resolveProvider } from 'agent-afk';
providerForModel('sonnet'); // 'anthropic-direct'
providerForModel('gpt-4o'); // 'openai-compatible'
providerForModel('Qwen/Qwen2.5-72B'); // 'openai-compatible' (any org/model id)| Model id pattern | Routes to |
|---|---|
claude-*, opus, sonnet, haiku, auto, local-* | anthropic-direct |
gpt-*, o1* / o3* / o4*, codex-*, and any org/model id (contains /) | openai-compatible |
grok-* | xai (authentication selects API key or OAuth unless forced) |
These are default routing rules. Explicit provider selection, slot bindings, and custom endpoints can change the result.
resolveProvider() returns a fresh provider instance for a model; providerForModel() returns
just the provider name. To inject your own backend (or a mock in tests), pass a provider
instance directly on the config:
import { AgentSession, type ModelProvider } from 'agent-afk';
// Your application supplies an implementation of the exported interface.
function createSession(provider: ModelProvider) {
return new AgentSession({ model: 'sonnet', provider });
}See Models & Providers for the full routing rules and local OpenAI-compatible runners (MLX, llama.cpp, vLLM, Ollama).
Permissions
Do not use AgentConfig.tools.allowedTools or disallowedTools as a security boundary on the direct SDK path. Those fields do not reach the direct providers' dispatcher permission lists. Use canUseTool, or inject a provider with its own enforced permissions. AFK's tool policy is not an operating-system shell sandbox.
Intercept every tool call with a canUseTool hook. The createCanUseToolHook() helper builds
one from simple allow/deny/ask rules and returns a function you pass straight to the config:
import { AgentSession, createCanUseToolHook } from 'agent-afk';
const canUseTool = createCanUseToolHook({
rules: {
defaultMode: 'allow',
tools: {
bash: 'deny', // runtime tool names (snake_case), not "Bash"
edit_file: 'deny',
write_file: 'ask',
},
},
onAsk: async ({ toolName }) => {
// Replace with your own approval UI. Deny if no approval is available.
return { behavior: 'deny', reason: `Approval required for ${toolName}` };
},
onDecision: (ctx, decision) => console.log(ctx.toolName, decision.behavior),
});
const session = new AgentSession({ model: 'sonnet', canUseTool });Rule keys must be AFK runtime tool names in snake_case: bash, edit_file, write_file,
read_file, not the PascalCase display names. A key that doesn't match a real runtime name
silently falls through to defaultMode; a Bash key with defaultMode: 'allow' fails open
and the tool you meant to block still runs.
The helper's ask rule falls through to allow if you omit onAsk. Provide a handler when approval must be mandatory.
For coarser control, set permissionMode on the config ('default', 'acceptEdits',
'plan', 'bypassPermissions'). See Permissions for the full
model.
Streaming event reference
Both query() and session.sendMessageStream() yield the OutputEvent union. The most common
variants:
event.type | Payload | Meaning |
|---|---|---|
chunk | event.chunk (a MessageChunk) | Streaming delta. Text is chunk.type === 'content' → chunk.content; also 'thinking', 'tool_use', 'tool_result' |
message | event.message (a Message) | A complete message emitted during the turn |
done | event.metadata? | The turn finished; metadata carries usage/cost |
error | event.error (an Error) | The turn failed |
paused / resumed | reason / account info | A usage limit was reported or a wait ended; inspect autoResume before assuming replay |
Read text with the two-level guard shown throughout this page:
if (event.type === 'chunk' && event.chunk.type === 'content') {
process.stdout.write(event.chunk.content);
}AgentConfig
The config object accepted by AgentSession (and, with model optional, by the one-shot
helpers). Commonly used fields:
| Field | Type | Notes |
|---|---|---|
model | AgentModelInput | Required for AgentSession. Alias (sonnet) or full id |
apiKey | string | Optional, falls back to the provider's credential resolution |
systemPrompt | string | Your system instructions, assembled with runtime tool and context instructions |
maxTurns | number | Cap session turns; use maxToolUseIterations for tool-use rounds |
tools | { allowedTools?, disallowedTools? } | Not an enforcement boundary on the direct SDK path; use canUseTool |
customTools | CustomToolDef[] | In-process tools from tool() |
executors | SessionExecutors | Opt-in dispatch bundle, one per session |
canUseTool | CanUseTool | Per-call permission hook |
permissionMode | PermissionMode | default / acceptEdits / plan / bypassPermissions |
maxBudgetUsd | number | Throw BudgetExceededError past this spend |
abortSignal | AbortSignal | External cancellation |
provider | ModelProvider | Inject a custom/mock backend |
resume / forkSession / persistSession | string / boolean / boolean | Session continuity controls |
The full type ships with the package; your editor's
autocomplete on the agent-afk import is the authoritative reference.
Full example
A minimal multi-turn program with a custom tool and a budget cap:
import { AgentSession, tool } from 'agent-afk';
import { z } from 'zod';
const now = tool(
'now',
'Return the current ISO timestamp.',
z.object({}),
async () => ({ content: new Date().toISOString() }),
);
const session = new AgentSession({
model: 'sonnet',
systemPrompt: 'You are a concise assistant. Use tools when useful.',
customTools: [now],
maxBudgetUsd: 0.25,
});
try {
const a = await session.sendMessage('What time is it right now?');
console.log(a.content);
const b = await session.sendMessage('How many minutes until the next hour?');
console.log(b.content);
} finally {
await session.close();
}