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.

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 zod

zod 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(...)
ShapeOne-shot functionsLong-lived object
StateFresh session per call, with optional resumeHolds 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 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 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

Restore 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.

ConditionBehavior
Explicit resumeMessages or resumeHistoryYour supplied context wins, even if empty
sessionId without resumeNames the session; does not load prior conversation
persistSession: falseDisables this automatic disk restoration
AFK_MESSAGE_JOURNAL_DISABLED=1Journal restoration is disabled
Missing or empty journalContinues without automatically loaded context
Malformed journal that throws during hydrationWarns on stderr and continues without prior context
Forked child configurationUses 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

Surfaceagentskillcompose
REPL, chat, Telegram, daemon, webwired by surfacewired by surfacewired by surface
SDK default (bare new AgentSession)offoffoff
SDK with executorsper toggleper toggleper 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 it

Hooks: fail-closed by default

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

  • opts.hookRegistry supplies an object implementing the HookRegistry interface.
  • 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 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 and pluginConfigs. Pass agent definitions through pluginConfigs, not through the session config.
  • config.mcpServers:
    • Executor path (SDK bundles via createWiredExecutors): MCP tools require a caller-owned config.mcpManager — pass a pre-built manager instance. The executor path does not construct one from the server list.
    • Root AgentSession (direct use without executors): mcpServers is honoured; the session builds, connects, and owns the manager automatically. A caller-supplied mcpManager is never disconnected by the SDK on session close. Failures surface in mcpServerStatus() / waitForInitialization(). SDK-built servers default to the trusted user-global layer (no project env-containment).

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 patternRoutes 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.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 infoA 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:

FieldTypeNotes
modelAgentModelInputRequired for AgentSession. Alias (sonnet) or full id
apiKeystringOptional, falls back to the provider's credential resolution
systemPromptstringYour system instructions, assembled with runtime tool and context instructions
maxTurnsnumberCap session turns; use maxToolUseIterations for tool-use rounds
tools{ allowedTools?, disallowedTools? }Not an enforcement boundary on the direct SDK path; use canUseTool
customToolsCustomToolDef[]In-process tools from tool()
executorsSessionExecutorsOpt-in dispatch bundle, one per session
canUseToolCanUseToolPer-call permission hook
permissionModePermissionModedefault / acceptEdits / plan / bypassPermissions
maxBudgetUsdnumberThrow BudgetExceededError past this spend
abortSignalAbortSignalExternal cancellation
providerModelProviderInject a custom/mock backend
resume / forkSession / persistSessionstring / boolean / booleanSession 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();
}

On this page