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.
Agent AFK is not just a CLI. The agent-afk package ships a typed programmatic surface, so
you can embed the same agent loop the afk binary runs — multi-provider routing, tools,
subagents, permissions, structured output — directly inside your own TypeScript.
The npm package declares a proper library entry (main, types, and an exports map), so a
plain import { query } from 'agent-afk' resolves without building from source.
You configure the whole loop — prompts, gates, routing, tools, and terminal behavior. The package is plain TypeScript: the code you import is the code you can read and change.
Install
npm install agent-afkSet a provider credential the same way the CLI does — the simplest path is an environment
variable that the default (Anthropic) provider reads when you don't pass apiKey explicitly:
export ANTHROPIC_API_KEY=sk-ant-...Requires Node.js ≥ 22. 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 | None — constructs, runs, and closes a session for you | 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 always closes the underlying session when the loop ends — on normal
completion, an early break, or a thrown error — so you never leak a session.
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', // AgentModelInput — alias or full id; defaults to 'sonnet'
systemPrompt: 'You are a terse assistant.',
maxTurns: 20,
tools: { allowedTools: ['read_file', 'grep', 'glob'] },
customTools: [getWeather], // in-process tools — see "Custom tools" below
canUseTool, // permission hook — see "Permissions" below
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.',
tools: { allowedTools: ['read_file', 'bash', 'edit_file'] },
// 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 workCustom 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) => {
const tempC = await lookupTemp(city);
return { 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
tools: { allowedTools: ['read_file', 'grep', 'glob'] },
},
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();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 — the child physically cannot mutate the repo.
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 (afk interactive) | always on | always on | always on |
afk chat (one-shot) | always on | always on | always on |
| Telegram bot | always on | always on | always on |
| Daemon | always on | always on | always on |
afk web (web surface) | always on | always on | always on |
SDK default (bare new AgentSession) | off | off | off |
SDK with executors | per toggle | per toggle | per toggle |
Pass opts to createWiredExecutors to control which tools the model sees. When opts is omitted, all three are enabled. When it is supplied, an omitted toggle means off:
import { createWiredExecutors, AgentSession } from 'agent-afk';
// Build the bundle — one per AgentSession.
const { executors, dispose } = createWiredExecutors(
{ model: 'sonnet', apiKey: process.env.ANTHROPIC_API_KEY },
{
agent: true, // expose agent tool
skill: ['search', 'my-plugin:review'], // expose skill tool, allowlisted names only
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 });
const reply = await session.sendMessage('Summarize open TODOs under src/.');
await session.close(); // drains the bundle; dispose() is an idempotent alternativequeryText and query accept executors via QueryOptions. 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.hookRegistryis aHookRegistryinstance.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 the CLI surfaces, SDK sessions do NOT scan ~/.afk/plugins by default. pluginConfigs defaults to [], meaning no plugins are loaded and no home-directory discovery runs. Pass an explicit list of SdkPluginConfig entries to opt in to specific plugins.
Fields ignored on the SDK executor path
Two AgentConfig fields are silently warned and ignored when building a bundle:
config.agents: named agents come from the built-in/user/project registry andpluginConfigs. Pass agent definitions throughpluginConfigs, not through the session config.config.mcpServers: MCP tools require a caller-ownedconfig.mcpManager. Build and pass a manager instance directly; the executor path does not construct one from the server list.
Budget: tree-wide, checked at child completion
AgentConfig.maxBudgetUsd covers the cumulative cost of the root session AND all its subagent children. Child cost is accumulated at child completion -- not mid-flight -- so a child that finishes just over the ceiling raises BudgetExceededError on the NEXT tool call in the parent, not mid-child.
Background jobs unavailable
The bash tool's run_in_background: true flag is not available on the sdk surface. SDK sessions use the synchronous tool path only.
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:
// WRONG: reusing a bundle throws on the second bind
const { executors } = createWiredExecutors({ model: 'sonnet' }, { 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' }, { unattended: true });
return new AgentSession({ model: 'sonnet', executors });
}Process-level environment caps
Depth, nesting, and credential resolution are process-global. AFK_MAX_NESTING_DEPTH (and the other AFK_* env caps) apply to SDK-spawned subagents exactly as they do to CLI ones. API keys are resolved through the same credential resolver the agent layer uses -- not re-read from the session config at every depth.
Providers and models
AFK routes each session to a provider based on the model id. You rarely need to touch this — the model string is enough — but the routing is inspectable:
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 |
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 } from 'agent-afk';
import { myCustomProvider } from './my-provider.js';
const session = new AgentSession({ model: 'sonnet', provider: myCustomProvider });See Models & Providers for the full routing rules and local OpenAI-compatible runners (MLX, llama.cpp, vLLM, Ollama).
Permissions
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, input }) => {
// resolve interactively, from a policy, etc.
return { behavior: 'allow' };
},
onDecision: (ctx, decision) => 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.
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 | Provider hit a usage limit and is waiting to auto-resume |
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 env credential |
systemPrompt | string | Appended to the framework base as an operator overlay |
maxTurns | number | Cap the agentic loop length |
tools | { allowedTools?, disallowedTools? } | Tool allow/deny lists (runtime names) |
customTools | CustomToolDef[] | In-process tools from tool() |
mcpServers | record | External MCP servers (stdio / http / sse) |
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 | — | Session continuity controls |
The full type (with ~40 exported type aliases) 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();
}