Skip to main content

Multi-Agent Agency API

Live runs: side-by-side code + captured output for both the graph strategy (research → writer ‖ illustrator → reviewer DAG) and the sequential strategy with streaming + HITL approval are on the agentos.sh demo gallery. Source: examples/agency-graph.mjs, examples/agency-streaming.mjs.

agency() is the high-level multi-agent factory in AgentOS. It coordinates a named roster of sub-agents under a chosen orchestration strategy and returns a single Agent-compatible interface so callers can swap a single agent for an entire team without changing call sites.

Implemented features: strategy orchestration, session history, aggregate usage/cost tracking, resource controls, HITL, guardrail evaluation, structured Zod output, RAG context injection (v1 placeholder), listen() for voice WebSocket transport, connect() for channel adapters, and real per-agent streaming events on the sequential strategy.

Mental Model​

Agency is the multi-brain primitive. Each agent() in the roster carries a full GMI brain: cognition (PAD mood, HEXACO traits, eight cognitive mechanisms), memory (episodic, semantic, procedural, working), persona, and tools. The agency layer adds three things on top of those brains.

1. Orchestration strategy declares how outputs flow between brains. Sequential chains them, parallel fans them out and synthesises, debate has them argue and refine, hierarchical lets a coordinator dispatch, graph DAG declares explicit dependencies. The strategy is the data-flow shape. Picking the strategy picks how brain-to-brain context propagates; the work itself happens inside each brain.

2. The strategy connects the agents. It decides the order and what each agent receives: the previous output (sequential), the same prompt (parallel), every prior argument (debate), the manager's delegations (hierarchical) or its predecessors' outputs (graph); the hierarchical strategy can add specialists at run time through EmergentAgentForge. agency() builds no shared memory store and runs no retrieval of its own: the memory and rag options are accepted and deferred on this path (see the capability contract), and AgencyMemoryManager and AgentCommunicationBus are exported classes a host wires itself.

3. A team-wide coordination shell wraps the whole agency. HITL approval gates (hitl.approvals.beforeTool is forwarded to every member), resource controls (the token, time and call caps on controls, enforced across the run) and structured Zod output (output). These apply to the team rather than per-agent. Options the lightweight path accepts and defers (security, permissions, observability, rag, memory) take effect on the full runtime; see the capability contract.

Three frames worth keeping:

  • Per-agent isolation holds. Each member is its own lightweight agent() with its own session history; nothing is shared between members except what the strategy passes between them.
  • Strategy is the flow, not the work. Strategies define how outputs propagate between members. The work happens inside each member; the strategy decides who reads what and when.
  • Flows compose. agency() returns an Agent, so an entire agency can sit inside another agency, or as a step inside workflow(), or as a node inside AgentGraph. The brain abstraction stacks at every layer.

The agency() factory returns an Agent-compatible interface, so anywhere you called agent().generate(input) you can drop in agency().generate(input). The team becomes a single composable unit.

Scope: when to reach for agency()​

agency() is for single-request multi-agent coordination — patterns where one external request produces one coordinated multi-agent response. It is deliberately not a general "all multi-agent patterns" abstraction; some multi-agent patterns belong in your own orchestrator with agent() plus agentos's lower-level primitives.

PatternUse agency()?Why
Research workflow: user asks a question, a researcher → writer → reviewer team produces one answerYesSingle request → coordinated response is the canonical fit
Customer support escalation: one user message routed through triage → specialist → supervisorYesSame shape — one in, one coordinated out
Code review pipeline: one PR, parallel reviewers (style + security + tests) produce one reviewYes (use parallel or graph)Fan-out / fan-in over a single input
Multi-agent debate to consensus on a single questionYes (use debate)The strategy is built for it
Long-running world simulation where a fixed roster of agents all run in parallel every turn against an evolving world state (e.g. paracosm)NoEach simulation turn is closer to N independent agent().session() calls coordinated by your loop than to one agency().generate() call. Build your own turn loop; use agent() + (optionally) EmergentAgentForge / EmergentAgentJudge for runtime synthesis
Multi-turn conversation with persistent agent roster, shared AgencyMemoryManager, and persistent specialists from spawn_specialist across turnsNot yetagency().session() exists but only persists message history + usage. Roster, shared memory, and tier: 'session' synthesised specialists reset between .send() calls. Build your own multi-call coordination on top, or open an issue describing the use case
Companion app where a single agent has a persistent identity across many user turnsNo — use agent() directlyagent().session() already covers this. agency() is overkill for single-agent stateful chat

The shared rule: if your problem decomposes into "one external request → one coordinated response", agency() is the right primitive. If your problem is fundamentally multi-turn with state evolving between turns, build your own orchestrator and reach into agentos for the lower-level primitives (agent(), AgencyMemoryManager, AgentCommunicationBus, EmergentAgentForge, EmergentAgentJudge, the cognitive memory layer) directly.


Table of Contents​

  1. Mental Model
  2. Scope: when to reach for agency()
  3. API Hierarchy
  4. Minimal Example
  5. Models, Providers and Keys per Agent
  6. Orchestration Strategies
  7. Adaptive Mode
  8. Emergent Agent Creation
  9. Human-in-the-Loop (HITL)
  10. Memory and RAG
  11. Voice and Channels
  12. Guardrails and Security
  13. Permissions
  14. Resource Controls
  15. Observability and Callbacks
  16. Structured Output with Zod
  17. Nested Agencies
  18. Hierarchical Delegation — Manager Dispatches Dynamically
  19. Full-Featured Example

API Hierarchy​

AgentOS exposes a layered public API. Each layer adds coordination features on top of the one below it.

generateText() — single stateless LLM call, no history
└── agent() — stateful multi-turn session, optional tools
└── agency() — multi-agent team with orchestration strategy
└── workflow() — imperative DAG of agency runs
└── AgentGraph — programmatic graph builder (advanced)

Use the lowest layer that satisfies your requirements:

Entry pointAdds over previousBest for
generateText()Nothing — raw callOne-shot prompts, evals
streamText()Streaming tokensChat UIs, long responses
generateImage()Image generationVisuals, multi-modal pipelines
agent()Session history, toolsSingle-agent assistants
agency()Multi-agent orchestration, HITL, guardrails, controlsResearch pipelines, content teams, autonomous workflows
workflow()Imperative DAG sequencing of agenciesMulti-stage pipelines with branching logic
AgentGraphProgrammatic graph construction + edge callbacksCustom topologies, dynamic routing

Minimal Example​

Three lines to create and run a two-agent research pipeline:

import { agency } from '@framers/agentos';

const team = agency({
agents: {
researcher: { instructions: 'Find relevant facts.' },
writer: { instructions: 'Write a clear, concise summary.' },
},
strategy: 'sequential',
});

const result = await team.generate('Summarise recent advances in fusion energy.');
console.log(result.text);

Set OPENAI_API_KEY (or another provider's key) and the agency auto-detects the provider. Pass provider: 'openai', model: 'gpt-6-astra' (or any other provider/model pair) to control the model explicitly.


Models, Providers and Keys per Agent​

Every roster entry is a BaseAgentConfig or a pre-built agent(). For a config entry, model, provider, apiKey and baseUrl set on the seat win over the agency-level values, and each of the four is inherited on its own when the seat leaves it out. A seat's tools are merged with the agency's tools. Nothing else is inherited: effort, thinking, maxTokens and instructions apply to the seat that sets them, and agency-level effort, thinking and maxTokens reach neither the seats nor the chair. Agency-level instructions go to the chair (parallel), the judge (debate) and the coordinator (hierarchical). output is read at the agency level only: its schema is applied to the final text, and a seat's own output has no effect. A pre-built agent() in the roster runs as it is and inherits nothing. The agency's hitl.approvals.beforeTool list is not copied into any seat: it is enforced per call, on config seats and pre-built seats alike (see Approval triggers).

const team = agency({
// Default for seats that set nothing, and the chair for parallel, debate and hierarchical.
provider: 'openai', model: 'gpt-6-astra',
agents: {
drafter: { instructions: 'Draft the answer.' }, // openai / gpt-6-astra
checker: { provider: 'anthropic', model: 'claude-opus-5-5', effort: 'high',
instructions: 'Check every claim in the draft.' },
local: { provider: 'ollama', model: 'llama3.2', baseUrl: 'http://127.0.0.1:11434',
instructions: 'Summarize in three sentences.' },
keyed: { provider: 'gemini', model: 'gemini-3.1-pro-preview', apiKey: process.env.GEMINI_REVIEW_KEY,
instructions: 'Give a second opinion.' },
},
strategy: 'sequential',
});

Because the four values are inherited one by one, set provider, model and the key together on every seat of a multi-vendor roster:

  • A seat that sets provider but not model inherits the agency's model id. An Anthropic seat that inherits gpt-6-astra gets a 404 from Anthropic, which is not retried on another provider, and the seat fails.
  • An agency-level apiKey or baseUrl belongs to the provider the agency's own calls go to. A config seat that sets none inherits them only when its calls go to that same provider; a seat whose calls go to another provider (its own provider, or one its model names, described next) does not, so an OpenAI key never reaches Anthropic through a seat. Nor does a seat whose calls go to auto-detection: under an agency that names its provider only by its model's prefix (model: 'openai:gpt-4.1'), a seat with a plain model (gpt-4.1-mini) has no provider to inherit, so it goes to whichever provider the environment's keys select and uses that provider's key. Write the prefix on the seat's model (openai:gpt-4.1-mini) or set its provider to share the agency's key. When the agency's provider is left to auto-detection (no provider and no prefix on its model), it is unknown, and every seat that sets none inherits the agency's key and URL.
  • A seat value set explicitly to undefined (for example apiKey: process.env.UNSET_VAR) counts as set and blocks inheritance. A seat that sets provider: undefined goes to auto-detection, so under an agency whose provider is known it does not inherit the agency's key or URL.

A seat model written as provider:model (anthropic:claude-opus-5-5) names its provider and does not inherit the agency's provider. A call goes to the provider its model prefix names (provider:, which wins over provider except under provider: 'ollama', or provider/ when provider is unset or repeats it; an id that also holds a colon takes the provider/ form only when provider repeats it), or else to its provider. Under provider: 'openrouter' a vendor/model id (anthropic/claude-sonnet-5-5) stays an OpenRouter id and the seat keeps the agency's OpenRouter key. A colon splits an id only when its prefix is a provider agentos knows: qwen2.5:7b under provider: 'ollama' and meta-llama/llama-3.3-70b-instruct:free under provider: 'openrouter' stay whole, and under provider: 'ollama' an id is never split. A colon id whose prefix is not a provider agentos knows, with no provider, is rejected: qwen2.5:7b alone throws and asks for provider: 'ollama', rather than being sent to whichever cloud provider the environment's keys select. An Ollama tag named after a provider (mistral:7b) is the one case to watch: as a seat model under an Ollama agency it names the Mistral provider and goes to Mistral's API, so a seat that means the local tag writes provider: 'ollama' itself.

Keys resolve per seat: the seat's apiKey, else the agency's apiKey when the seat inherits it (above), else a key set with setDefaultProvider() (used when that default names no provider or names the seat's provider), else the provider's environment variable (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, and so on). A comma-separated key value becomes a rotating key pool. A seat with no key at all fails without failover, with one exception: provider: 'anthropic' with no Anthropic key but an OPENROUTER_API_KEY in the environment is routed through OpenRouter as anthropic/<model> and reports provider: 'openrouter'.

Options passed to generate(prompt, options) apply to every seat and to the chair. A model or provider passed there replaces every seat's own setting, so configure models on the roster, not per call.

Each seat keeps the default provider failover. When a seat's call fails with a retryable error (HTTP 401, 402, 403, 429, 500, 502, 503, 504 or 529, a network error or a timeout), the call is retried on the providers whose keys are in the environment. With no policyTier set, the order and models are: OpenAI gpt-5.6-sol, Anthropic claude-sonnet-5-5 (effort low), OpenRouter openai/gpt-5.6-sol, Gemini gemini-3.1-pro-preview; the seat's own provider is skipped. The seat's result then reports the provider that answered, but agentCalls does not record it. To pin every seat and the chair to their configured providers, pass generate(prompt, { fallbackProviders: [] }); to pin one seat, put a pre-built agent({ ..., fallbackProviders: [] }) in the roster.

The per-seat ledger agentCalls records each seat's name, input, output, tool calls, usage and duration; the chair's own call is not in it. The result's top-level provider and model are the chair's for parallel, debate and hierarchical, the last seat's for sequential, and the last result of the final tier for graph. review-loop results and stream() results carry neither.


Orchestration Strategies​

sequential (default)​

Agents run one after another. Each agent receives the previous agent's output as context, forming a progressive refinement chain.

const pipeline = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
researcher: { instructions: 'Gather facts on the topic.' },
editor: { instructions: 'Edit for clarity and concision.' },
reviewer: { instructions: 'Check tone and factual accuracy.' },
},
strategy: 'sequential',
});

const { text, agentCalls } = await pipeline.generate('Write about quantum computing.');
console.log(agentCalls.length); // 3 — one record per agent

parallel​

All agents run concurrently on the same prompt. A synthesis step merges their outputs using the agency-level model and provider (the chair). Requires model or provider at the agency level.

Each seat can run on its own provider, model and key, so one question can be put to several vendors at once and the chair reads every answer:

const panel = agency({
// The chair: synthesizes the seats' answers.
provider: 'anthropic', model: 'claude-opus-5-5',
agents: {
claude: { provider: 'anthropic', model: 'claude-opus-5-5', effort: 'high', instructions: 'Review the change for defects.' },
gpt: { provider: 'openai', model: 'gpt-6-astra', effort: 'high', instructions: 'Review the change for defects.' },
gemini: { provider: 'gemini', model: 'gemini-3.1-pro-preview', effort: 'high', instructions: 'Review the change for defects.' },
},
strategy: 'parallel',
quorum: { minAgents: 2, minProviders: 2 },
});

const change = `
- if (attempt > maxRetries) throw err;
+ if (attempt >= maxRetries) throw err;
`;

const { text, agentCalls } = await panel.generate(`Review this change for defects:\n${change}`);
console.log(agentCalls.map((c) => c.agent)); // the seats that answered

Provider quorum​

quorum sets a floor on the panel after the fan-out and before synthesis:

  • minAgents: how many seats must succeed. A seat succeeds when its call resolves and no HITL gate rejected it.
  • minProviders: how many distinct provider ids must be among the successful seats. The id is the provider each seat's result reports, which is the provider that answered: a seat served by its failover chain counts as the failover provider. Ids are not vendors: OpenRouter is one provider whatever model it serves, so an openai seat plus a seat that failed over to OpenRouter's openai/gpt-5.6-sol leg passes minProviders: 2 on one vendor.
  • onShortfall: 'error' (default) throws AgencyQuorumError before synthesis; 'proceed' logs a warning and synthesizes anyway. The error class is not exported from the package entry point; test err.name === 'AgencyQuorumError'.

Three things the quorum does not check: a seat that returned empty text still counts as succeeded; two seats on different models of one provider count as one provider; and with adaptive: true the agency compiles the hierarchical strategy whatever strategy says, so the quorum is skipped without a warning. Only parallel enforces quorum.

debate​

Agents argue over maxRounds rounds (default: 3), speaking in roster order. The first speaker of round one gets the motion alone and writes an opening statement. Every later turn gets the motion plus the transcript of all earlier turns, including the current round's, and must quote the strongest opposing claim, attack it and add one new point in about 150 words. After the last round a judge built from the agency-level model or provider writes the verdict, so one of the two is required. Putting the two sides on different vendors keeps one model from arguing with itself:

const debaters = agency({
provider: 'openai', model: 'gpt-6-astra', // the judge
agents: {
proponent: { provider: 'anthropic', model: 'claude-opus-5-5',
instructions: 'Defend your position vigorously.' },
critic: { provider: 'gemini', model: 'gemini-3.1-pro-preview',
instructions: 'Challenge every claim you hear.' },
},
strategy: 'debate',
maxRounds: 4,
});

const { text } = await debaters.generate('Is remote work better than in-office?');

review-loop​

One agent produces output; another reviews it and requests revisions. The loop continues until the reviewer is satisfied or maxRounds is reached. The first roster entry is the producer and the second the reviewer; each runs on its own model, so the reviewer can come from a different vendor than the drafter.

const loop = agency({
agents: {
drafter: { provider: 'openai', model: 'gpt-6-astra',
instructions: 'Draft a press release.' },
reviewer: { provider: 'anthropic', model: 'claude-opus-5-5',
instructions: 'Review for brand voice and accuracy. Request changes if needed.' },
},
strategy: 'review-loop',
maxRounds: 3,
});

const { text } = await loop.generate('Announce our new product launch.');

hierarchical​

A coordinator agent dispatches sub-tasks to specialist agents via tool calls. The coordinator decides which agents to invoke and in what order at runtime. Required for emergent agent synthesis.

const team = agency({
provider: 'openai', model: 'gpt-6-astra', // the coordinator
agents: {
researcher: { provider: 'gemini', model: 'gemini-3.1-pro-preview', instructions: 'Find factual information.' },
coder: { provider: 'anthropic', model: 'claude-opus-5-5', instructions: 'Write and explain code.' },
writer: { instructions: 'Produce polished prose.' }, // inherits openai / gpt-6-astra
},
strategy: 'hierarchical',
});

const { text } = await team.generate('Explain and demonstrate the quicksort algorithm.');

graph​

Agents declare explicit dependencies via dependsOn. The orchestrator topologically sorts agents into tiers and runs each tier concurrently. Every agent receives the original user prompt plus the concatenated plain-text outputs of its direct dependencies.

Auto-detection: when any agent in the roster has a dependsOn array, the strategy is automatically set to 'graph' — you don't need to specify it explicitly (though doing so is fine).

Cycle detection: the orchestrator validates the dependency DAG at construction time and throws if it contains a cycle.

Context passing: each agent's prompt is assembled as:

<original user prompt>

--- Output from <dependencyName> ---
<plain text output>

There is no expression language (no $steps.<name> references). Each agent simply receives plain text from its predecessors.

Agent config — dependsOn​

OptionTypeDefaultDescription
dependsOnstring[][]Names of agents in the same agency that must complete before this agent runs. Agents with no dependsOn are roots and execute first.

Full example — research team​

const team = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
// Tier 0 — no dependencies, runs first
researcher: {
instructions: 'Research the topic thoroughly. Provide facts, statistics, and sources.',
},

// Tier 1 — both depend on researcher, run concurrently
writer: {
instructions: 'Write a polished article based on the research provided.',
dependsOn: ['researcher'],
},
illustrator: {
instructions: 'Describe 3 illustrations that would complement the article.',
dependsOn: ['researcher'],
},

// Tier 2 — depends on both writer and illustrator, runs last
reviewer: {
instructions: 'Review the article and illustrations for consistency and accuracy.',
dependsOn: ['writer', 'illustrator'],
},
},
strategy: 'graph', // optional — auto-detected from dependsOn
});

const { text, agentCalls } = await team.generate('Write about the James Webb Space Telescope.');
console.log(text);
console.log(agentCalls.map(c => `${c.agent} (${c.durationMs}ms)`));
// researcher (2100ms)
// writer (1800ms) — ran concurrently with illustrator
// illustrator (1200ms) — ran concurrently with writer
// reviewer (1500ms)

Streaming​

const stream = team.stream('Write about the James Webb Space Telescope.');
for await (const chunk of stream.textStream) {
process.stdout.write(chunk);
}

Important: textStream is the raw live stream. If output guardrails or beforeReturn HITL approval rewrite the answer, the finalized output is available via:

  • stream.text
  • stream.finalTextStream
  • final-output events on stream.fullStream

Use textStream for low-latency token UX and finalTextStream when the client must only ever see the approved answer. See Streaming Semantics for the exact contract.


Adaptive Mode​

Set adaptive: true to let the orchestrator choose the best strategy at runtime based on task complexity signals. The default strategy acts as a hint; the coordinator may override it.

const smart = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
analyst: { instructions: 'Analyse data and trends.' },
reporter: { instructions: 'Write clear reports.' },
},
strategy: 'sequential', // default hint
adaptive: true, // may switch to hierarchical if the task is complex
});

const { text } = await smart.generate('Analyse this dataset and write a report.');

Adaptive mode is also the second way to unlock emergent agent synthesis (the first is strategy: 'hierarchical').


Emergent Agent Creation​

When enabled, the orchestrator may synthesise new specialist agents at runtime to handle tasks not covered by the statically defined roster. Mechanically, the hierarchical manager gets one extra tool — spawn_specialist({ role, instructions, justification? }) — alongside its delegate_to_<name> tools. Calling it forges a new sub-agent via EmergentAgentForge and (when judge: true) gates it through EmergentAgentJudge before it joins the live roster. Emergent agents are also subject to HITL approval when hitl.approvals.beforeEmergent is set.

Emergent requires either strategy: 'hierarchical' or adaptive: true.

const research = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
researcher: { instructions: 'Find sources and pull verbatim quotes.' },
writer: { instructions: 'Write clear, well-cited prose.' },
},
strategy: 'hierarchical',
emergent: {
enabled: true,
tier: 'session', // 'session' | 'agent' | 'shared'
judge: true, // EmergentAgentJudge gates each spawn
planner: {
maxSpecialists: 3, // hard cap per run; default 5
requireJustification: true, // manager must explain each spawn
},
},
});
tierLifetime of synthesised agents
"session"Discarded when the generate() call ends
"agent"Persist for the lifetime of the agency instance
"shared"Persist globally across all agency instances
planner fieldDefaultEffect
maxSpecialists5Hard cap on successful synthesis count per run. Past the cap, spawn_specialist returns an error to the manager.
requireJustificationfalseForces the manager to supply a justification string explaining why no static agent fits. Surfaces on the emergentForge callback's ForgeEvent.
maxJudgeCallsmaxSpecialists * 2Bounds the judge LLM cost — counts rejected spawns too (the judge already ran). Has no effect when judge: false.
judgeModelsmall-model default per provider (gpt-4o-mini, claude-haiku-4-5-20251001, gemini-2.5-flash, llama-3.3-70b-versatile) → falls back to agency modelOverride when you want a more capable judge or the small-model default does not exist for your provider.

Tested rejection paths (each surfaces a structured tool-result error the manager can recover from): empty instructions, reserved role name (e.g. spawn_specialist, final_answer), invalid identifier (spaces / leading digit), missing justification when required, maxSpecialists cap reached, maxJudgeCalls cap reached, role collision with existing roster entry, HITL beforeEmergent rejection, judge rejection, judge LLM error / malformed JSON.


Human-in-the-Loop (HITL)​

Gate any lifecycle point behind an async approval handler.

Built-in handlers​

import { hitl } from '@framers/agentos';

hitl.autoApprove() // always approve — use in tests / CI
hitl.autoReject('dry-run mode') // always reject with an optional reason
hitl.cli() // interactive stdin/stdout prompt
hitl.webhook('https://my-service/ok') // POST to an HTTP endpoint
hitl.slack({ channel: '#approvals', token: process.env.SLACK_BOT_TOKEN })
hitl.llmJudge({ // delegate to an LLM judge
model: 'gpt-4o-mini',
criteria: 'Is this action safe and non-destructive?',
confidenceThreshold: 0.8,
fallback: hitl.cli(), // escalate uncertain decisions to human
})

Approval triggers​

const guarded = agency({
provider: 'openai', model: 'gpt-4o',
agents: { worker: { instructions: 'Execute tasks.' } },
hitl: {
approvals: {
beforeTool: ['delete-record', 'send-email'],
beforeAgent: ['financial-agent'],
beforeEmergent: true,
beforeReturn: true,
beforeStrategyOverride: true,
},
handler: hitl.autoApprove(), // replace with hitl.cli() in production
timeoutMs: 30_000,
onTimeout: 'reject', // 'reject' | 'approve' | 'error'
guardrailOverride: true, // run post-approval safety checks after HITL
postApprovalGuardrails: ['pii-redaction', 'code-safety'],
},
});

beforeTool lists tool names, or '*' for every tool. A listed call waits for the handler on every tool loop the agency runs: native tool calls, the prompt-tool path (toolMode: 'prompt', and 'auto' after a provider rejects native tools) and streamed calls, on config seats, pre-built seats, the hierarchical manager and the specialists it spawns, and nested agencies.

  • The handler is asked after the seat's or the caller's onBeforeToolExecution has run, so it approves the arguments the tool will run with. A hook that returns null skips the tool without asking; a hook that throws is logged and the handler is asked. A modifications.toolArgs on the decision is not applied: rewrite arguments in the hook.
  • A rejection skips the tool and the model is told; the run goes on.
  • A handler that throws, and a timeout under onTimeout: 'error', skip the tool, go to on.error, and reject the call with that error once the strategy has settled, after the run's usage has been added to the agency totals. Every later tool call of the run is skipped without asking the handler. No finalization step runs: no output guardrails, no beforeReturn approval, no agentEnd and no validation retry. Under stream() the result's promises reject with the error, and textStream and fullStream end by throwing it.
  • After the handler approves, the post-approval guardrails (hitl.postApprovalGuardrails, default pii-redaction and code-safety) run over the arguments unless hitl.guardrailOverride is false; a block skips the tool and fires guardrailHitlOverride.
  • A pre-built seat enforces beforeTool only when it forwards per-call options to generateText or streamText, as every agent() and agency() does; a custom Agent that drops unknown per-call keys runs its listed tools unasked.
  • A parent agency's gate holds inside the agencies it nests and is asked first: after a parent refusal the nested agency's handler is not asked, and a parent handler error rejects the parent call while the nested call completes.

onBeforeToolExecution runs on the prompt-tool path as it does on native tool calls, for every generateText and streamText caller.

Custom handler​

const custom = agency({
agents: { worker: { instructions: 'Do work.' } },
hitl: {
approvals: { beforeReturn: true },
handler: async (request) => {
// request.type, request.agent, request.action, request.description
const ok = await myApprovalDatabase.lookup(request.id);
return {
approved: ok,
reason: ok ? 'Approved by policy' : 'Blocked by policy',
modifications: ok ? undefined : { output: '[redacted]' },
};
},
},
});

LLM-as-judge handler​

hitl.llmJudge() delegates approval decisions to an LLM that evaluates the request against configurable criteria and returns a structured decision with a confidence score. When the LLM's confidence falls below the threshold, the decision is escalated to a fallback handler.

import { agency, hitl } from '@framers/agentos';

// Automated quality gate — LLM decides, uncertain cases go to a human.
const qualityGated = agency({
provider: 'openai', model: 'gpt-4o',
agents: { writer: { instructions: 'Write marketing copy.' } },
hitl: {
approvals: { beforeReturn: true },
handler: hitl.llmJudge({
model: 'gpt-4o-mini',
criteria: 'Is this response factually accurate, on-brand, and free of hallucinations?',
confidenceThreshold: 0.8,
fallback: hitl.cli(), // low-confidence decisions escalate to interactive CLI
}),
},
});

hitl.llmJudge() options:

OptionTypeDefaultDescription
modelstring'gpt-5.6'Judge model. The default comes from the central judge resolver; AGENTOS_JUDGE_MODEL overrides it.
providerstring'openai'Judge provider (AGENTOS_JUDGE_PROVIDER overrides the default). Pinning a provider other than openai requires an explicit model.
effortstring'max'Reasoning effort. The default applies only when the resolver also chose the model; a caller-pinned model gets no effort unless one is passed.
criteriastring'Evaluate whether this action is safe, relevant, and appropriate.'Custom rubric the judge evaluates against.
confidenceThresholdnumber0.7Confidence threshold (0-1). Below this the fallback handler is used.
fallbackHitlHandlerhitl.autoReject(...)Handler invoked when confidence is below threshold or LLM call fails.
apiKeystring-Optional API key override.

Memory and RAG​

agency() accepts memory and rag for forward compatibility with the full runtime and does not act on them: the capability contract marks both as accepted and deferred on the lightweight path, no shared memory store is built, and injectRagContext() returns the prompt unchanged because no vector store is initialised (a documents list only logs guidance to use the runtime). Each member keeps its own session history for the duration of one generate() or stream() call; inside agency().session(), only the user/assistant message history and aggregate usage persist between .send() calls.

To give members memory, pass built agents as roster members: an agent() with a memoryProvider keeps its hooks when it sits in a roster, and a member can carry a soul file the same way.

import { agent, agency, type AgentMemoryProvider } from '@framers/agentos';

const myMemory: AgentMemoryProvider = {
getContext: async (text) => ({ contextText: await lookupNotes(text) }), // injected as a system message before the call
observe: async (turn) => { await saveNotes(turn); }, // runs after the reply
};

const researcher = agent({
provider: 'openai', model: 'gpt-4o',
instructions: 'Research the topic.',
memoryProvider: myMemory,
});

const team = agency({
provider: 'openai', model: 'gpt-4o', // inherited by the inline writer
agents: { researcher, writer: { instructions: 'Write from the research.' } },
strategy: 'sequential',
});

Cognitive memory (the CognitiveMemoryManager, its mechanisms, the memory bridge) and the RAG pipeline (vector stores, GraphRAG, HyDE, reranking) run on the full runtime: see Memory Model, Cognitive Memory and the RAG guides.


Voice and Channels​

Voice pipeline​

When voice.enabled is true the agency exposes a listen() method that starts a local WebSocket server. Callers receive the bound port and URL and can connect any audio client. The full STT → LLM → TTS pipeline is provided by src/io/voice-pipeline/; the agency wires generate() as the LLM backend.

const voiceAgent = agency({
provider: 'openai', model: 'gpt-4o',
agents: { assistant: { instructions: 'You are a helpful voice assistant.' } },
voice: {
enabled: true,
transport: 'streaming',
stt: 'deepgram',
tts: 'elevenlabs',
ttsVoice: 'rachel',
endpointing: 'silero-vad',
bargeIn: 'threshold',
language: 'en-US',
},
});

// Bind to an OS-assigned port; connect audio clients to the returned URL.
const server = await voiceAgent.listen();
console.log(`Voice WS server ready at ${server.url}`);
// ...
await server.close();

Requires the ws package (npm install ws).

Channel adapters​

When channels contains at least one entry the agency exposes a connect() method so the surface matches the full runtime. The lightweight agency() constructs no channel adapters: connect() rejects with the configured channel names. Channel wiring (Discord, Telegram, Slack and the rest) is done with ChannelRouter and the adapters in src/io/channels/ (the Channels guide shows the standalone form), or by the full AgentOS runtime with its messaging-channel extension packs; connect() never does it.

const social = agency({
provider: 'openai', model: 'gpt-4o',
agents: { community: { instructions: 'Engage helpfully with community messages.' } },
channels: {
discord: { token: process.env.DISCORD_BOT_TOKEN, guildId: '...' },
telegram: { token: process.env.TELEGRAM_BOT_TOKEN },
slack: { token: process.env.SLACK_BOT_TOKEN, signingSecret: '...' },
},
});

try {
await social.connect();
} catch (error) {
// agency().connect() cannot connect "discord", "telegram", "slack": the
// lightweight agency() helper constructs no channel adapters ...
console.error((error as Error).message);
}

Guardrails and Security​

Shorthand (applies to both input and output)​

const safe = agency({
provider: 'openai', model: 'gpt-4o',
agents: { assistant: { instructions: 'Be helpful.' } },
guardrails: ['pii-redaction', 'toxicity-filter', 'grounding-guard'],
});

Structured guardrails config​

const audited = agency({
provider: 'openai', model: 'gpt-4o',
agents: { assistant: { instructions: 'Be helpful.' } },
guardrails: {
input: ['injection-shield', 'pii-redaction'],
output: ['grounding-guard', 'code-safety'],
tier: 'strict',
},
security: { tier: 'balanced' }, // 'dangerous'|'permissive'|'balanced'|'strict'|'paranoid'
});

Security tiers​

TierDescription
"dangerous"No restrictions — internal trusted pipelines only
"permissive"Most capabilities on; network + filesystem allowed
"balanced"Sensible defaults; destructive actions require approval
"strict"Read-only filesystem, no shell spawn, narrow tool allow-list
"paranoid"Minimal surface; all side-effecting tools blocked

Permissions​

const restricted = agency({
provider: 'openai', model: 'gpt-4o',
agents: { analyst: { instructions: 'Analyse data.' } },
permissions: {
tools: ['read-file', 'query-db'], // explicit allow-list
network: false,
filesystem: true,
spawn: false,
requireApproval: ['delete-record'], // not enforced by agency(): list tools in hitl.approvals.beforeTool
},
});

Resource Controls​

Hard and soft limits on token spend, duration, and call counts.

const budgeted = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
a: { instructions: 'Step 1.' },
b: { instructions: 'Step 2.' },
},
strategy: 'sequential',
controls: {
maxTotalTokens: 50_000, // across all agents in the run
maxCostUSD: 0.50,
maxDurationMs: 30_000,
maxAgentCalls: 20,
maxStepsPerAgent: 5,
maxEmergentAgents: 3,
onLimitReached: 'warn', // 'stop' | 'warn' | 'error'
},
on: {
limitReached: (e) => {
console.warn(`Limit breached: ${e.metric} = ${e.value} (limit ${e.limit})`);
},
},
});

Observability and Callbacks​

Lifecycle callbacks​

const observed = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
researcher: { instructions: 'Research.' },
writer: { instructions: 'Write.' },
},
strategy: 'sequential',
observability: {
logLevel: 'info',
traceEvents: true,
otel: { enabled: true },
},
on: {
agentStart: (e) => console.log(`[START] ${e.agent} — ${e.input.slice(0, 60)}`),
agentEnd: (e) => console.log(`[END] ${e.agent} — ${e.durationMs}ms`),
handoff: (e) => console.log(`[HANDOFF] ${e.fromAgent} -> ${e.toAgent}: ${e.reason}`),
toolCall: (e) => console.log(`[TOOL] ${e.agent} called ${e.toolName}`),
guardrailResult: (e) => console.log(`[GUARD] ${e.guardrailId}: ${e.passed ? 'pass' : 'block'}`),
emergentForge: (e) => console.log(`[FORGE] ${e.agentName} approved=${e.approved}`),
approvalRequested: (e) => console.log(`[HITL] ${e.type}: ${e.description}`),
limitReached: (e) => console.warn(`[LIMIT] ${e.metric}: ${e.value}/${e.limit}`),
error: (e) => console.error(`[ERROR] ${e.agent}: ${e.error.message}`),
},
});

Provenance / audit trail​

const auditable = agency({
provider: 'openai', model: 'gpt-4o',
agents: { worker: { instructions: 'Do auditable work.' } },
provenance: {
enabled: true,
hashChain: true,
record: { toolCalls: true, agentOutputs: true },
export: 'jsonl', // 'jsonl' | 'otlp' | 'solana'
},
});

Structured Output with Zod​

Pass a Zod schema to output and the final agent's response is validated and parsed against it. The result's object field carries the typed value.

import { z } from 'zod';
import { agency } from '@framers/agentos';

const schema = z.object({
title: z.string(),
summary: z.string(),
keyPoints: z.array(z.string()).min(3),
sentiment: z.enum(['positive', 'neutral', 'negative']),
});

const extractor = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
extractor: { instructions: 'Extract structured information from the text.' },
},
output: schema,
});

const result = await extractor.generate('...article text...');
const data = result.object as z.infer<typeof schema>;
console.log(data.title, data.keyPoints);

Nested Agencies​

An agency() instance satisfies the Agent interface and can be placed directly in another agency's agents roster. The outer strategy treats it as a single opaque agent call.

import { agency } from '@framers/agentos';

// Inner agency — dedicated research pipeline
const researchTeam = agency({
provider: 'openai', model: 'gpt-4o-mini',
agents: {
searcher: { instructions: 'Search for sources.' },
analyst: { instructions: 'Analyse and rank sources.' },
},
strategy: 'sequential',
});

// Outer agency — uses researchTeam as one of its agents
const publishingTeam = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
researchTeam, // nested agency
writer: { instructions: 'Write from research.' },
editor: { instructions: 'Polish and fact-check.' },
},
strategy: 'sequential',
});

const { text, agentCalls } = await publishingTeam.generate('Write about quantum computing.');
// agentCalls[0] represents the entire researchTeam run as a single call

Nesting can go arbitrarily deep. usage and agentCalls are aggregated through all layers. close() propagates inward — the outer agency calls close() on every nested agency in its roster.


Hierarchical Delegation — Manager Dispatches Dynamically​

In the hierarchical strategy, a coordinator agent receives delegation tools for every specialist in the roster. The coordinator decides which agents to invoke, in what order, and how to merge their outputs -- all at runtime. Combine with emergent so the coordinator can also synthesise new specialists on the fly when the static roster is insufficient.

import { agency, type ITool } from '@framers/agentos';

// Stand-ins for the host-supplied tools the researcher delegates to.
// Replace with real implementations (Tavily, arxiv-api, etc.).
const webSearchTool: ITool = {
name: 'web_search',
description: 'Search the web.',
inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
execute: async ({ query }) => ({ success: true, output: `(stub) ${query}` }),
};
const arxivTool: ITool = {
name: 'arxiv_search',
description: 'Search arXiv for papers.',
inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
execute: async ({ query }) => ({ success: true, output: `(stub) arxiv: ${query}` }),
};

// Hierarchical agency where the coordinator delegates dynamically.
// The coordinator is not a roster entry: it is built from the agency-level
// model and provider, and the agency-level `instructions` are added to its brief.
const dynamicTeam = agency({
provider: 'openai', model: 'gpt-6-astra',
instructions: 'Delegate research, analysis, and writing tasks to the specialists.',
agents: {
researcher: {
provider: 'gemini', model: 'gemini-3.1-pro-preview',
instructions: 'Find information from web and academic sources.',
tools: [webSearchTool, arxivTool],
},
analyst: {
provider: 'anthropic', model: 'claude-opus-5-5',
instructions: 'Analyze data and extract insights.',
},
writer: {
instructions: 'Write polished content based on research and analysis.', // inherits openai / gpt-6-astra
},
},
strategy: 'hierarchical',
// the coordinator gets delegate_to_researcher, delegate_to_analyst, delegate_to_writer tools
});

const result = await dynamicTeam.generate(
'Write a comprehensive report on the state of AI agents in 2026'
);
console.log(result.text);

Adding Emergent Agent Creation​

Enable emergent so the manager can spawn ad-hoc specialists via the spawn_specialist tool when the predefined roster does not cover a sub-task. With judge: true, EmergentAgentJudge runs one LLM-as-judge call evaluating the spec on safety / scope / risk before it joins the roster.

const emergentTeam = agency({
provider: 'openai', model: 'gpt-4o',
agents: {
researcher: {
instructions: 'Research topics using web and academic sources.',
tools: [webSearchTool, arxivTool],
},
writer: {
instructions: 'Produce polished, well-structured content.',
},
},
strategy: 'hierarchical',
emergent: {
enabled: true,
tier: 'session', // synthesised agents discarded after generate() returns
judge: true, // EmergentAgentJudge gates each spawn
planner: {
maxSpecialists: 3, // hard cap per run
requireJustification: true, // manager must explain each spawn
},
},
hitl: {
approvals: { beforeEmergent: true },
handler: async (request) => {
console.log(`Emergent agent requested: ${request.description}`);
return { approved: true };
},
},
on: {
emergentForge: (e) =>
console.log(`[FORGE] ${e.agentName} — approved: ${e.approved}`),
},
});

// The manager may spawn a "data-visualiser" or "statistician" agent on the fly
const report = await emergentTeam.generate(
'Analyze the 2026 State of AI survey data and produce an executive brief with charts'
);
console.log(report.text);

The following example combines all major features in one agency configuration.

import { z } from 'zod';
import { agency, hitl } from '@framers/agentos';

// Reusable inner team handling research
const researchTeam = agency({
provider: 'openai', model: 'gpt-4o-mini',
agents: {
searcher: { instructions: 'Search for authoritative sources on the topic.' },
fact: { instructions: 'Verify claims and flag unsupported assertions.' },
},
strategy: 'sequential',
controls: { maxTotalTokens: 20_000 },
});

// Outer agency orchestrating the full content pipeline
const contentPipeline = agency({
name: 'content-pipeline',
provider: 'openai', model: 'gpt-4o',

agents: {
research: researchTeam, // nested agency
writer: {
instructions: 'Write a compelling, well-structured article.',
maxSteps: 5,
},
editor: {
instructions: 'Edit for clarity, grammar, and brand voice.',
},
},

strategy: 'sequential',
adaptive: true,

emergent: {
enabled: true,
tier: 'session',
judge: true,
},

guardrails: {
input: ['injection-shield'],
output: ['grounding-guard', 'pii-redaction'],
tier: 'balanced',
},

hitl: {
approvals: {
beforeReturn: true,
beforeEmergent: true,
},
handler: hitl.autoApprove(), // swap for hitl.cli() or hitl.webhook() in production
timeoutMs: 60_000,
onTimeout: 'reject',
},

controls: {
maxTotalTokens: 100_000,
maxCostUSD: 2.00,
maxDurationMs: 120_000,
maxAgentCalls: 50,
onLimitReached: 'warn',
},

on: {
agentStart: (e) => console.log(`[>] ${e.agent}`),
agentEnd: (e) => console.log(`[<] ${e.agent} (${e.durationMs}ms)`),
limitReached: (e) => console.warn(`limit: ${e.metric} = ${e.value}`),
error: (e) => console.error(`error in ${e.agent}: ${e.error.message}`),
},

output: z.object({
title: z.string(),
body: z.string(),
wordCount: z.number(),
readingTime: z.string(),
}),
});

// Non-streaming call
const result = await contentPipeline.generate('Write an article about large language models.');
console.log(result.text);
console.log(result.agentCalls.length, 'agent calls');
console.log(result.usage.totalTokens, 'total tokens');

// Streaming call
const stream = contentPipeline.stream('Write about transformers.');
for await (const chunk of stream.textStream) {
process.stdout.write(chunk);
}

// Multi-turn session
const session = contentPipeline.session('article-001');
await session.send('Outline the article.');
await session.send('Now write the introduction.');
const history = session.messages(); // [{role:'user',content:'...'}, ...]
console.log(history.length, 'messages in session');

// Cleanup
await contentPipeline.close();

See Also​