agentafk

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-afk

Set 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(...)
ShapeOne-shot functionsLong-lived object
StateNone — constructs, runs, and closes a session for youHolds history across turns
LifecycleManaged automatically (always closes)You call close()
Use whenA single prompt-to-answer callMulti-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 done

Conversation 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 resources

Budgets 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 work

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) => {
    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

Surfaceagentskillcompose
REPL (afk interactive)always onalways onalways on
afk chat (one-shot)always onalways onalways on
Telegram botalways onalways onalways on
Daemonalways onalways onalways on
afk web (web surface)always onalways onalways on
SDK default (bare new AgentSession)offoffoff
SDK with executorsper toggleper toggleper 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 alternative

queryText 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 it

Hooks: fail-closed by default

createWiredExecutors refuses to build a bundle unless one of these conditions is met:

  • opts.hookRegistry is a HookRegistry instance.
  • config.hookRegistry (on the AgentConfig passed as the first argument) is set.
  • opts.unattended: true is 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 only search -- not my-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 and pluginConfigs. Pass agent definitions through pluginConfigs, not through the session config.
  • config.mcpServers: MCP tools require a caller-owned config.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 patternRoutes 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.typePayloadMeaning
chunkevent.chunk (a MessageChunk)Streaming delta. Text is chunk.type === 'content' → chunk.content; also 'thinking', 'tool_use', 'tool_result'
messageevent.message (a Message)A complete message emitted during the turn
doneevent.metadata?The turn finished; metadata carries usage/cost
errorevent.error (an Error)The turn failed
paused / resumedreason / account infoProvider 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:

FieldTypeNotes
modelAgentModelInputRequired for AgentSession. Alias (sonnet) or full id
apiKeystringOptional — falls back to the provider's env credential
systemPromptstringAppended to the framework base as an operator overlay
maxTurnsnumberCap the agentic loop length
tools{ allowedTools?, disallowedTools? }Tool allow/deny lists (runtime names)
customToolsCustomToolDef[]In-process tools from tool()
mcpServersrecordExternal MCP servers (stdio / http / sse)
canUseToolCanUseToolPer-call permission hook
permissionModePermissionModedefault / acceptEdits / plan / bypassPermissions
maxBudgetUsdnumberThrow BudgetExceededError past this spend
abortSignalAbortSignalExternal cancellation
providerModelProviderInject 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();
}

Next steps

On this page