TUBELESS v0.3.11

Agents and durable execution

tubeless/agent builds an ordinary pipeline around a bounded decision loop. Each turn requests a decision, validates it, executes a batch of registered tools, and reduces their outcomes into the next state. A finish decision validates and returns the agent’s result. Two factories share that loop:

  • defineAgent({ id, model }) uses a model as the decision source. Its default { task } → { answer } shape serves prompts and the tubeless agent REPL; an inputSchema, task mapper and resultSchema make it a typed step that works autonomously on upstream pipeline data. See model-backed agents.
  • defineAgentLoop({ id, decide, … }) takes your own decide callback and state for scripted, rule-based or hand-integrated decisions.

The scripted agent recipe runs without model credentials:

make run FILE=examples/agent.ts ARGS='--question "hello tubeless"'
make run FILE=examples/agent.ts ARGS='--question "missing word"'
make run FILE=examples/agent.ts ARGS='--dry-run --question "preview"'
make plan FILE=examples/agent.ts

It creates a variable number of calls, preserves their decision order, recovers from a deliberate domain error, and transforms the final answer. Its defineAgentLoop decide callback is scripted; most model-backed work uses defineAgent below instead.

Default model-backed agent

For a terminal prompt loop with model selection and the live pipeline reporter, use tubeless agent --model gpt-5.4-mini. Enter a task, read the answer, then prompt again; /model <name> changes the next task’s model while retaining context. Completed prompts share conversation state through a session; /clear starts fresh. --prompt 'task' runs once, --model-module ./model.ts loads a custom provider factory, and --store or --trace records each prompt’s run for tubeless history and Studio. See the agent CLI for setup, cancellation, budgets, and the plugin contract.

To use an existing agent with its own model, tools and environment, run tubeless agent <agent-id|file>. Use --project for an explicit project or --export for a named file export. The CLI accepts agents with the default { task } and { answer } schemas and preserves their configuration; use tubeless run for typed agents. See agent selection and the credential-free chat recipe.

For general workspace tasks, supply a model and an ID:

import { defineAgent } from "tubeless/agent";
import { openaiModel } from "tubeless/agent/openai";

const agent = defineAgent({ id: "coding", model: openaiModel() });
const { answer } = await agent.runOrThrow(
  { task: "Find and fix the failing test, then verify the change." },
  undefined,
  { cwd: "/path/to/project" }
);

Choose a model provider

model accepts any AgentModel. Tubeless ships two optional, dependency-free adapters with the same option names, so switching providers changes one import and one call:

import { defineAgent } from "tubeless/agent";
import { anthropicModel } from "tubeless/agent/anthropic";
import { openaiModel } from "tubeless/agent/openai";

// Claude through the Anthropic Messages API (ANTHROPIC_API_KEY).
const claude = defineAgent({ id: "coding", model: anthropicModel() });

// OpenAI through the Responses API (OPENAI_API_KEY).
const gpt = defineAgent({ id: "coding", model: openaiModel() });
Option anthropicModel() openaiModel()
model ANTHROPIC_MODEL, else claude-opus-5-5 OPENAI_MODEL, else gpt-5.4-mini
apiKey ANTHROPIC_API_KEY (x-api-key); else ANTHROPIC_AUTH_TOKEN as a bearer OPENAI_API_KEY (bearer)
authToken Explicit bearer token, for compatible servers that expect one Not applicable (apiKey is already a bearer)
baseUrl ANTHROPIC_BASE_URL, else https://api.anthropic.com (no /v1) OPENAI_BASE_URL, else https://api.openai.com/v1
reasoningEffort Omitted; low through max sends output_config.effort Omitted; none through max sends reasoning
compactAfterBytes 768 KiB; null disables compaction 768 KiB; null disables compaction
timeoutMs 600000 per decision 60000 per decision
maxRetries 2 extra attempts per HTTP request; 0 disables Same
maxTokens 32000 output tokens per request, thinking included Not applicable
strict true; false sends tools without strict mode Always strict
promptCaching true (5 minutes), "5m", "1h", or false to disable caching Not applicable

Every environment value is read when a decision executes, so imports, plans and dry runs need no credentials. Options always take precedence over the environment.

timeoutMs bounds one decision, including any compaction request. When it elapses, the decision fails with OpenAI decision timed out after N ms (or Anthropic …) and the run reports failed, not cancelled. Only the run’s own abort signal cancels it. The executable Claude recipe is registered as claude-coding-agent beside the OpenAI-backed coding-agent. To use another server that implements either API, see compatible APIs.

The factory supplies the { task: string } input and { answer: string } result schemas, default tools, a short coding prompt, conversation state, and reduction. Add instructions to customize its behavior, tools to extend or override the defaults, and limits to bound execution. It remains an ordinary pipeline; pipelineTool, project registration, traces, and cancellation work as before. Use defineAgentLoop with decide when you need custom state or decisions.

Prompt caching and usage traces

Both adapters preserve native history and keep tool definitions stable during a run. OpenAI uses automatic prompt caching. Anthropic places an explicit cache breakpoint after the shared system instructions and a moving automatic breakpoint on the conversation. The system breakpoint lets separate tasks reuse the same tools and instructions, subject to the provider’s minimum cacheable length. Set anthropicModel({ promptCaching: "1h" }) for longer pauses; the one-hour lifetime has a higher write price. promptCaching: false omits all cache controls for compatible servers that reject them. See OpenAI caching and Anthropic caching.

Both adapters compact completed history at 768 KiB by default. Anthropic also compacts when the complete decision request, including tools and instructions, exceeds 768 KiB, leaving 256 KiB below the 1 MiB ceiling for the next assistant reply and compaction envelope. This full-request trigger applies even with a larger explicit history threshold. Newly added tool results can be shortened to fit the compaction request. OpenAI also compacts when a complete decision would exceed the request ceiling. These byte thresholds preserve longer reusable prefixes; they are not token counts or a guarantee that a model’s context window will fit. For smaller-context models, set a lower compactAfterBytes using observed token usage, with room for tool results and model output. null disables all compaction triggers but still enforces the HTTP request limit.

With tracing enabled (--trace, --store, or a tracing.exporter), every provider request emits a step.attempted event with agent.model.* attributes:

Attribute Meaning
provider, name, operation Provider, selected model, and decision or compaction
request, durationMs, responseReceived One-based request within the decision callback, elapsed time, and whether a parsed response arrived
httpStatus HTTP status of a failed exchange, when a response arrived
inputTokens, outputTokens Provider-reported usage; input includes cached reads and writes
cacheReadTokens, cacheWriteTokens Cached input reads and writes, when reported
cacheWrite5mTokens, cacheWrite1hTokens Anthropic writes by lifetime, when reported
cacheWrite30mTokens, cacheWrite12hTokens OpenAI writes by lifetime, when reported
uncachedInputTokens, reasoningTokens Anthropic uncached input or OpenAI reasoning output, when reported

For example, agent.model.cacheReadTokens is the full attribute name. Missing or invalid counters stay absent; zero means the provider explicitly reported zero. Each retried attempt is its own event with the next request number, and durationMs excludes the backoff wait. Anthropic input totals require valid uncached input in every usage entry and valid cache counters wherever supplied; missing optional cache counters contribute zero. A missing or invalid required counter omits the total while other valid counters remain available. Anthropic compaction includes the billed counters from usage.iterations. Reminder requests and compaction calls have their own events, including usage reported on responses that later fail validation. Transport failures carry no token estimate. Only allowlisted numeric usage and request metadata are recorded, without prompt, response, or credential content. tubeless history <run-id> sums them per agent and per run: the text view prints a Usage line per agent and a Usage total line per provider and model, and --json adds usage on each turn, usage (this agent’s own turns) and subtreeUsage (own turns plus every descendant agent) on each agent, and agentHistory.usage for the run. An absent counter was never reported, never zero; requestsWithoutUsage above zero makes the sums a lower bound; the printed percentage is cache-read tokens divided by input tokens. Tubeless reports tokens only, so apply the selected model’s prices to the per-model sums yourself.

Chat sessions

Each run of a defineAgent agent starts a fresh conversation. To continue one across prompts, as the tubeless agent REPL does, wrap the agent in a session:

import { createAgentSession, defineAgent } from "tubeless/agent";

const agent = defineAgent({ id: "assistant", model: openaiModel() });
const session = createAgentSession(agent);
await session.runOrThrow({ task: "Summarize the open issues in README.md" });
await session.runOrThrow({ task: "Now draft a fix for the first one" }); // same conversation
await saveConversation(session.conversation); // plain provider-owned data
const resumed = createAgentSession(agent, { conversation: await loadConversation() });

The conversation advances only when a run completes and finalizes; failed, cancelled and dry runs keep the previous one. clear() starts fresh. Runs are sequential: a run started while another is active rejects with TUBELESS_AGENT_SESSION_BUSY. Each run still gets fresh limits, its own trace and isolated turn state; only the provider conversation carries over. A persisted conversation must come from the same provider adapter. Sessions work with typed agents too. defineAgentLoop agents own their state and have no provider conversation, so they are rejected.

With a durable agent, replaying a completed task restores its final conversation without repeating model requests, tools, or result validation. Use a distinct execution key for each new task. Completed checkpoints written by older versions may lack the final conversation: ordinary agent replay still returns the saved answer, but session replay rejects with TUBELESS_AGENT_SESSION_CHECKPOINT_INCOMPLETE. Read that answer outside a session, then seed a session from separately persisted conversation for new work; seeding alone does not make that older task replayable in a session.

Sessions use the configuration captured by defineAgent, including its model, tool grants and limits. Changing the original configuration object or its collections later does not change the agent or sessions created from it.

Typed inputs and results

Supply inputSchema with a task mapper, and optionally resultSchema, to run the same model-backed agent over typed pipeline data. The mapper turns validated options into the model’s task text (nonblank, up to 16 KiB); the model finishes against resultSchema through context.resultJsonSchema, and the harness validates the result. durability.key callbacks receive the typed options. The agent composes like any child pipeline, as in the extract recipe:

const TriageAgent = defineAgent({
  id: "triage-agent",
  model: openaiModel(),
  inputSchema: extracted, // { files: string[] }
  resultSchema: triage, // { files: { path, kind }[] }
  task: ({ files }) => `Classify each extracted file:\n${files.map((f) => `- ${f}`).join("\n")}`,
  defaultTools: ["read"],
});
const classify = fromPipeline("classify", {
  pipeline: TriageAgent,
  dependsOn: [extract],
  mapOptions: ({ extract }) => extract,
});

With openaiModel() or anthropicModel(), a resultSchema outside the provider’s strict-mode subset fails at definition; see descriptor checks. task is required exactly when inputSchema is set. Without both, the input stays { task: string }; without resultSchema, the result stays { answer: string }.

Typed agents default to projectContext: false: a short task-neutral prompt that names no tools or repository conventions, followed by the working directory and your instructions, which become the agent’s main guidance. Grant only the tools the task needs with defaultTools. Set projectContext: true for a typed agent that should behave as a coding agent in a repository.

With project context (the default for { task } agents), the prompt asks the agent to inspect before editing, preserve unrelated work, recover from tool errors, verify changes, and distinguish verified results from remaining limitations. At the first live decision, project context loads AGENTS.md from the nearest repository root through the run’s cwd, in that order. A live model-agent run resolves cwd to its physical directory once, before any decision or tool call. The prompt, file tools, bash, custom tools, and child pipelines all use that directory, including when resolving ... This also applies with projectContext: false; planning and dry runs do not resolve cwd. Guidance comes from the project containing the files. A .git file also marks a worktree root. Outside a repository it loads only cwd’s file. More specific directory instructions take precedence. Deeper directories and files referenced by those instructions are read by the agent when needed; there is no recursive startup scan or @file expansion. The prompt requires guidance reads and the file operations or task commands they govern to run in separate turns. Commands that only discover filenames may run before those reads. This also applies when a command selects a directory with bash’s cwd option or shell cd. The prompt requires trying that directory’s AGENTS.md even when filename discovery did not list it; a missing-file result permits proceeding. Beyond startup, discovery and ordering depend on the model; file tools do not enforce them as access controls. Required instruction discovery and reading precede a user’s requested first task action; optional exploration waits until after that action. Already supplied guidance need not be reread. Set projectContext: false to disable discovery and use the task-neutral prompt instead of the workspace prompt. Context is loaded once per invocation; simultaneous and later runs have independent state. Missing instruction files are allowed; unreadable, non-text, or oversized files fail before a model request rather than silently discarding guidance. Each file uses the read tool’s 16 KiB/2,000-line bounds; guidance paths must be nonblank and at most 4,096 characters. The complete prompt, including paths, headers and separators, is capped at 32 KiB of UTF-8 before each section is retained. Loading stops at the first section that exceeds the budget. Tasks are capped at 16 KiB of UTF-8.

AgentModel is a provider-independent callback. It receives the task, instructions, its previous plain-data conversation (initially null), ordered outcomes from the latest completed batch, and the usual decision context. It returns an untrusted decision plus the next plain-data conversation. Tubeless validates the decision and owns a frozen copy of that state; model definitions do not hold mutable sessions. Conversation state is accepted before dispatch and reduced after the whole tool batch settles successfully. Expected tool failures are outcomes; fatal failure or cancellation stops the run.

A model may also provide checkDescriptors({ capabilities, resultJsonSchema }), which throws when its provider cannot accept the agent’s tool or result schemas. defineAgent calls it once those descriptors are compiled, so an incompatible resultSchema or tool fails at definition with TUBELESS_AGENT_INVALID_DEFINITION rather than at the first request in a pipeline. openaiModel() and anthropicModel() provide it, running the same checks their requests run. Wrapping an adapter in another function drops the hook; the request still checks before any HTTP call.

The optional OpenAI subpath uses native fetch and adds no SDK dependency. It defaults to OPENAI_MODEL or gpt-5.4-mini; openaiModel({ model, apiKey }) supplies explicit values. The adapter omits reasoning settings by default so models that do not accept them remain compatible. Opt in with reasoningEffort for a model that supports it; null also omits the setting. The coding-agent recipe and live evaluations explicitly request "high". Supported effort values depend on the selected model; higher effort can increase latency and cost. Accepted settings are "none", "minimal", "low", "medium", "high", "xhigh", and "max"; check the selected model’s supported efforts. Model names are trimmed; a blank OPENAI_MODEL uses the default, while an explicit blank model is rejected locally. API keys are trimmed before use. Environment credentials are read at execution, so imports, plans, and dry runs need no API key. Live runs make paid API requests and execute workspace tools with the host’s existing permissions. Dry runs skip the model and have no final answer. HTTP 401 failures distinguish known invalid_api_key and ip_not_authorized codes and otherwise suggest checking credentials, project and organization access, and the IP allowlist. Error-body reads are limited to 16 KiB; raw messages and unknown codes are omitted, while validated request IDs support troubleshooting. See OpenAI’s error guide.

Custom tool descriptors must be inline and support OpenAI’s strict JSON Schema subset. The adapter wraps each argument in { input }; reference keywords ($ref, $dynamicRef, $recursiveRef) are rejected locally, at definition and before any HTTP request, because wrapping changes their reference root. Supply an inline inputJsonSchema override when the tool’s schema generates references. Every object, including nested and nullable objects, must set additionalProperties: false and list every property in required. These strict object requirements are checked locally with the tool name and schema path in the error. Other model-specific restrictions on the supported schema subset still apply.

The adapter keeps the complete Responses output sequence, including encrypted reasoning, messages, and function calls, then appends matching tool outputs. New tool outputs share an encoded 256 KiB batch budget, reduced further when the request being sent leaves less room. Batches that fit remain exact. Otherwise, each result’s fixed metadata is reserved first, and the remaining preview space is shared across large results. Small complete results are kept when they cost less than a preview marker. Large outputs carry truncated: true, the original JSON byte count, and a UTF-8-safe JSON-text preview. Every call still receives a matching output and retains its success/failure flag. The model is told to retrieve narrower results and avoid repeating successful mutations. Full tool outcomes remain available to the harness and evaluation reports. This provider boundary keeps large batches from preventing compaction; it does not change tool execution or the outcomes supplied to custom decide callbacks. It uses store: false with client-owned history. Once completed history exceeds compactAfterBytes (768 KiB by default), or a complete decision would exceed the request limit, it calls the native /responses/compact endpoint and carries the entire returned context window into the next decision. The compaction budget includes instructions and history; tool schemas are only included when checking the subsequent decision with the compacted window. This prevents large schemas from unnecessarily trimming results before compaction. Instructions are sent again on each request. Compaction can lose exact historical detail; the prompt tells the model to re-read sources when exact text matters. Select a model that supports both Responses function calling and compaction.

One decision has a combined 60-second deadline (timeoutMs) and makes at most two successful HTTP requests: optional compaction and the decision itself. Each request is retried up to maxRetries times (default 2) after HTTP 408, 429, 5xx or a dropped connection; waits honor Retry-After, stay inside timeoutMs, and maxRetries: 0 disables them. Harness decision budgets count callbacks, not these HTTP requests or tokens. Encoded requests are capped at 1 MiB and responses at 2 MiB; these byte limits are not model token estimates. If prior history or schemas leave no room even for the output markers, the run fails explicitly rather than dropping a call’s result. Compaction rejection, a still-oversized request, refusal, malformed output, and an incomplete response fail without silently dropping history and are never retried. Streaming and crash-safe resume are outside this helper’s contract. tubeless agent owns an in-memory REPL session around it, retaining completed conversation across user prompts. The adapter appends each new task on the first decision of a prompt, closes its synthetic _finish function call, and can compact completed history before appending the next user message. Direct defineAgent runs remain isolated.

The executable model agent recipe is registered as coding-agent in the example project. Run it from the workspace it should change:

export OPENAI_API_KEY=...
bun /path/to/tubeless/dist/workbench/workbench-bin.js run \
  /path/to/tubeless/examples/agent-model.ts -- --task 'Fix the failing test and verify it'

Anthropic adapter

anthropicModel() from tubeless/agent/anthropic calls the Messages API with native fetch and no SDK dependency. It follows the same harness contract as the OpenAI adapter: tool arguments are wrapped in { input }, _finish carries { result }, references are rejected locally, requests are capped at 1 MiB and responses at 2 MiB, and large tool batches receive the same marked previews.

The adapter keeps every returned content block, including thinking blocks and their signatures, and appends one user message of tool_result blocks per batch. A later prompt on the same conversation closes _finish with a tool_result and adds the new task to that user message, because the Messages API cannot follow a user message with another user message. Compaction runs on that completed history before the new task is appended, and the task stays verbatim. Direct defineAgent runs remain isolated. Recoverable tool failures set is_error: true. History is never edited except by compaction, which keeps it valid for models that check replayed thinking. Current Claude models reject forced tool choice, so the adapter sends tool_choice: "auto" and instructs the model to respond only with tool calls. If a reply contains no tool call, the adapter appends that reply and one reminder, then asks once more. A second text-only reply fails the decision. Requests cache both the shared system prefix and the growing conversation, as described in prompt caching and usage traces.

With strict: true (the default), tools use strict tool use. Every object must set additionalProperties: false; unlike OpenAI, optional properties are allowed. The schema the model sees keeps only what strict mode accepts: types, properties, required, items, anyOf, allOf (oneOf becomes anyOf), scalar enum and const, default, supported string formats, and minItems of 0 or 1. A schema with scalar enum or const and no type gets one: a shared type when every value agrees, number when the values are only integers and numbers, or anyOf of typed enums when the values mix types. A schema with neither a type nor anyOf/allOf is rejected locally. Every other keyword, such as length, numeric, and pattern constraints, is recorded in that schema’s description instead. Tubeless still validates every argument against the full schema. Tool names longer than 64 characters are rejected locally; the harness itself allows 128. Strict mode allows at most 20 strict tools per request, including _finish, plus limits on optional and union-typed parameters. The adapter rejects more than 20 locally, at definition. Set strict: false for larger tool sets or servers without strict support, or advertise fewer tools with defaultTools, allowlists or code mode.

Once completed history exceeds compactAfterBytes, or a complete decision exceeds 768 KiB including tools and instructions, the adapter requests on-demand compaction with the same system prompt and tools. It replaces the history with the returned assistant message, holding the signed summary block unchanged, followed by a short user continuation message. The compact-2026-09-04 beta header is sent only on that request and on later requests carrying the block. If no summary comes back, the decision continues with the uncompacted history and the request limit still applies. Set compactAfterBytes: null for servers or models without compaction.

A decision makes at most three successful HTTP requests: optional compaction, the decision, and one tool-call reminder. Each request is retried up to maxRetries times (default 2) after HTTP 408, 429, 5xx or a dropped connection, honoring Retry-After and never past timeoutMs; maxRetries: 0 disables them. max_tokens, refusal, and other unexpected stop reasons fail explicitly; raise maxTokens when long thinking reaches the cap. Run the Claude recipe the same way as the OpenAI one:

export ANTHROPIC_API_KEY=...
bun /path/to/tubeless/dist/workbench/workbench-bin.js run \
  /path/to/tubeless/examples/agent-model-anthropic.ts -- --task 'Fix the failing test and verify it'

Compatible APIs

Provider requests do not follow redirects. Configure the final API base URL, including any gateway path prefix. This keeps credential headers at the configured endpoint.

Both adapters work with any server that implements the same protocol: hosted routers, inference platforms, self-hosted servers, and gateways. Set baseUrl (or OPENAI_BASE_URL / ANTHROPIC_BASE_URL), the provider’s credential, and the provider’s model ID. Then turn off features the server does not support:

  • openaiModel() needs the Responses API (POST {baseUrl}/responses), not only Chat Completions. baseUrl includes the version path. Set compactAfterBytes: null unless the server implements /responses/compact. The adapter sends store: false and include: ["reasoning.encrypted_content"].
  • anthropicModel() needs the Messages API (POST {baseUrl}/v1/messages). baseUrl excludes /v1. Most compatible servers expect a bearer token, so pass authToken (or set ANTHROPIC_AUTH_TOKEN) instead of apiKey. Set strict: false, promptCaching: false, and compactAfterBytes: null unless the server documents strict tool use, cache_control, and on-demand compaction.
import { defineAgent } from "tubeless/agent";
import { anthropicModel } from "tubeless/agent/anthropic";
import { openaiModel } from "tubeless/agent/openai";

// OpenRouter through its Responses endpoint.
export const RouterAgent = defineAgent({
  id: "router-coding",
  model: openaiModel({
    baseUrl: "https://openrouter.ai/api/v1",
    apiKey: process.env.OPENROUTER_API_KEY,
    model: "anthropic/claude-opus-5",
    compactAfterBytes: null,
  }),
});

// Baseten through its Messages endpoint (bearer auth; x-api-key is rejected).
export const BasetenAgent = defineAgent({
  id: "baseten-coding",
  model: anthropicModel({
    baseUrl: "https://inference.baseten.co",
    authToken: process.env.BASETEN_API_KEY,
    model: "deepseek-ai/DeepSeek-V4-Pro",
    strict: false,
    promptCaching: false,
    compactAfterBytes: null,
  }),
});

process.env in a module is read at import, unlike the adapters’ own variables. To keep credentials out of imports and plans, set the conventional variables instead, such as OPENAI_BASE_URL and OPENAI_API_KEY, or ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN. Then call openaiModel() or anthropicModel() with only the remaining options. With ANTHROPIC_AUTH_TOKEN, leave ANTHROPIC_API_KEY unset; when both variables are set, the API key takes precedence.

These base URLs come from each provider’s documentation as of October 2026. Check the provider’s current documentation for model IDs, tool support, and limits:

Provider openaiModel({ baseUrl }) anthropicModel({ baseUrl }) Notes
OpenRouter https://openrouter.ai/api/v1 https://openrouter.ai/api Bearer auth (authToken); model slugs such as anthropic/claude-opus-5
Baseten Not available (Chat Completions only) https://inference.baseten.co Messages is in beta; bearer auth (authToken)
Fireworks https://api.fireworks.ai/inference/v1 https://api.fireworks.ai/inference Bearer auth; accounts/fireworks/models/... model IDs
DeepSeek https://api.deepseek.com https://api.deepseek.com/anthropic Ignores cache_control
Moonshot (Kimi) https://api.moonshot.ai/v1 https://api.moonshot.ai/anthropic Bearer auth (authToken)
Amazon Bedrock https://bedrock-mantle.{region}.api.aws/v1 https://bedrock-mantle.{region}.api.aws/anthropic Bedrock API key; Bedrock model IDs
Microsoft Foundry https://{resource}.openai.azure.com/openai/v1 https://{resource}.services.ai.azure.com/anthropic model is the deployment name
Ollama http://localhost:11434/v1 http://localhost:11434 Local keys can be any placeholder value
vLLM http://{host}:8000/v1 http://{host}:8000 Self-hosted
LiteLLM proxy http://localhost:4000/v1 http://localhost:4000 Supports /responses/compact; bridges providers without either API

Groq’s Responses endpoint rejects store and include, which the OpenAI adapter always sends. Google Vertex AI puts the model in the request path and uses OAuth instead of a base URL plus key. Reach either one through a gateway such as LiteLLM. Compatible servers vary in how closely they follow each protocol. Exercise a new provider with a small task before relying on it.

Run the opt-in live evaluations from the package root with bun run eval:agent. They create disposable workspaces and check investigation, actual edits, successful verification, startup project instructions, nested guidance discovered before a requested file read, aggregation across many files, recovery from a missing file, forced compaction, a typed agent given its input as task text or JSON, and a 49-tool inventory. Reports record each case’s tool count and descriptor bytes. Add --only=<id>,<id> to rerun selected cases. They also require existing tests and unrelated work to remain intact. bun run eval:agent -- --codemode runs the same cases through one codemode tool, except the typed triage cases: they have no tools, so they run directly in both modes. Each result records its effective codemode. Its checks are objective: fixture checks pass, preserved files stay intact, answers follow guidance and compaction still completes. Call-order assertions apply only to direct calls, since script calls are child runs; the report keeps every script for review in .context/model-agent-codemode-eval.json. Results are written to .context/model-agent-eval.json; these paid evaluations are separate from credential-free CI. Reports retain full call arguments, observed tool outcomes, decision batches with the outcomes supplied to each decision, and any completed answer and verification output, including when a later assertion fails. The disposable workspaces are still removed after each task. The recovery task must request the missing-file read alone, then choose recovery in the next decision that receives its ENOENT outcome. That batch’s results must contain the current configuration or reveal its location. Speculative recovery in the first batch and recovery deferred past the error-observing decision both fail the evaluation. The read-only nested-guidance task must load src/AGENTS.md in an earlier turn than src/code.txt, apply its answer convention, and preserve every fixture file. Passing them is evidence for these specific tasks, not a general reliability guarantee.

Connect a model

The OpenAI agent recipe connects the same tools to the Responses API using built-in fetch. Its decision adapter is application code, with no provider SDK or new Tubeless API. Replace decide: openaiDecision with another provider’s callback to keep the same tools, state, limits, and execution.

Set OPENAI_API_KEY in your environment, then run:

bun run tubeless -- run --trace logs/openai-agent.ndjson examples/agent-openai.ts -- \
  --question "Uppercase red, blue, and green in separate calls. Then count the characters in the uppercase results joined by single spaces."

The example defaults to gpt-4.1-mini; set OPENAI_MODEL to choose another Responses model with function calling. This command makes paid API requests. Its custom tools transform and count text. Like every agent, it also advertises the default workspace tools, including bash, to the model. Those tools execute with the host process’s permissions and environment; there is no per-call approval or sandbox built into the harness.

The model chooses which tools to call, their arguments, and how many calls to request. A run can uppercase three words in one turn, consume those results in a count call on the next turn, then finish with RED BLUE GREEN and a count of 14. The call sequence is model-selected; the recipe does not script those turns. The command prints the validated final answer.

The adapter follows OpenAI’s function calling protocol:

  • Tool descriptors come from context.capabilities. Each function wraps the raw tool argument in { input }, so even a string-valued tool has an object parameter schema. This recipe’s schemas support OpenAI strict mode. When adapting other tools, check the provider’s supported JSON Schema subset.
  • An adapter-only _finish function wraps context.resultJsonSchema in { result }. Calling it alone returns a Tubeless finish decision. Combining it with other calls fails before any tools start.
  • Each request sends the current question and accumulated, validated outcomes. Tubeless owns the state; this example makes independent model requests with store: false, without a provider conversation or replayed reasoning items.
  • Provider call IDs become turn-local Tubeless call IDs. The adapter checks the response envelope and parses arguments; Tubeless checks registered tools, call IDs, budgets, argument schemas, tool results, and the final answer.

The example allows six decisions and twelve tool calls, with up to three tools running concurrently. Each HTTP request has a 30-second deadline and receives the run’s cancellation signal. HTTP errors, incomplete responses, refusals, invalid JSON, and invalid decisions fail the run. There are no automatic provider retries. Error messages omit HTTP response bodies.

The recipe also bounds model input in UTF-8 bytes. Questions may use up to 4 KiB; accumulated observations may use up to 16 KiB of serialized JSON. An oversized question fails before the first model request. Oversized history fails before committing the next state or requesting another decision. Accepted observations remain complete, so later tools can use their exact text. The adapter checks the complete encoded HTTP body against a 32 KiB limit before every request, including instructions, schemas, and JSON escaping. These are application byte budgets; they do not measure a selected model’s token usage.

plan, inspect, and graph need no credentials and make no API requests. The live example has no preview decision source: --dry-run skips the agent and fails required finalization because there is no answer. Use the scripted recipe for a credential-free executable preview. Importing the project also makes no requests; openai-agent is registered in the example project.

CI replaces fetch with provider response fixtures while running real Tubeless tools, reduction, and finalization. Run the command above separately for a live provider check. Traces record turn/call lifecycles and summaries; prompts, state, tool output, and credentials are not added to traces by this adapter.

The compiled turn graph stays decide -> calls -> reduce. Its execution history expands with each chosen batch and turn; a model cannot rewrite dependency edges or add tool-registry entries. The registered bash tool executes model-supplied commands.

Connect MCP servers

The optional tubeless/agent/mcp entrypoint connects Model Context Protocol servers and exposes their tools to any agent. It is dependency-free and speaks Streamable HTTP (url) and local stdio (command); the generic agent entrypoint never imports it.

import { defineAgent } from "tubeless/agent";
import { connectMcpServers } from "tubeless/agent/mcp";
import { openaiModel } from "tubeless/agent/openai";

await using mcp = await connectMcpServers({
  docs: { url: "https://mcp.example.com/mcp", headers: { Authorization: `Bearer ${token}` } },
  files: { command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."] },
});
const agent = defineAgent({ id: "mcp-agent", model: openaiModel(), tools: mcp.tools });

Tools are named <server>__<tool>, with characters outside [A-Za-z0-9_-] replaced by _; server names use letters, digits and hyphens. tools is an ordinary registry: spread it beside custom tools or pass tools: ["name"] per server to expose only an allowlist. Each server accepts timeoutMs (default 60000) per request. Discovery rejects repeated cursors and is bounded to 1024 pages and 4096 tools per server.

Inventories are discovered once at connection, so the agent definition, its capability fingerprints and durable checkpoints see a fixed set. A server that changes its tools changes the agent’s definition identity. Connect inside the function that runs the agent rather than at module scope, so importing a project starts no processes, and close the connection after the last run, as in the MCP recipe. Calls after close() fail. When an HTTP server answers 404 for the session, the client starts a new session and retries that request once; the tool inventory is not rediscovered. Closing aborts active HTTP requests and bounds stdio shutdown, terminating the owned process group on POSIX. Descendants that escape that group may outlive the connection.

MCP input schemas are rewritten to the strict form model providers require: optional properties become required and nullable, every object is closed, and local $defs references are inlined. Nulls supplied for optional properties are removed before the call, including inside anyOf branches and array items. The adapter supports fixed-property objects, homogeneous arrays and anyOf. It rejects free-form objects, recursive references, tuple schemas, oneOf, allOf, conditionals and other unsupported structural constructs at connection time. Required names must be declared in properties. Optional properties that already accept null are rejected because strict descriptors cannot distinguish an omitted value from an explicit null; required nullable properties retain nulls. Arguments are structurally validated before dispatch, including structural equality for JSON-valued enum and const; the server validates the remaining constraints, including formats and ranges. Conversion bounds source data and expanded references with shared node, depth and text budgets. References permit descriptive annotations but reject validation siblings, whose constraints would need composition. Structurally matching anyOf branches must all decode identically, including branches with server-validated constraints such as patterns and ranges. Conflicting branch or sibling interpretations fail validation before a tool call is dispatched.

Results are { content, structuredContent? } from the server. When a tool lists an object outputSchema, code-mode declarations type structuredContent with it, required as the MCP specification requires. Local $defs and definitions references still resolve, including JSON Pointer escapes. Schemas that are missing, not object schemas, unserializable, or over 16 KiB of UTF-8 either serialized or as a rendered declaration (where references expand) keep the generic Record<string, unknown>. The schema describes results for the model; Tubeless does not validate structuredContent against it. A result with isError becomes a recoverable MCP_TOOL_ERROR observation, and JSON-RPC errors such as unknown arguments become MCP_PROTOCOL_ERROR. Timeouts, transport failures and exited servers are fatal. Cancellation sends notifications/cancelled to the server. MCP tools keep the default replay: "unsafe" and are skipped in dry runs; server annotations are not trusted to change either. The client declares no sampling, roots or elicitation capabilities and answers only ping. Stdio servers inherit the host environment plus env, and HTTP headers are sent as given: each server is a separate authority outside AgentEnvironment. HTTP requests, including session termination, do not follow redirects; configure the server’s final endpoint URL.

Code mode

Code mode is off by default. Set codemode: true on defineAgent or defineAgentLoop, or pass tubeless agent --codemode, to advertise one codemode tool instead of the registry. Each call runs a JavaScript async function body that calls the registered tools as typed async functions and returns a JSON value. Registered tools include the defaults, custom tools, pipeline tools and MCP tools. An agent needs at least one of them; code mode without tools fails at definition.

When to use code mode

Measure before enabling it. In the live evaluations (bun run eval:agent, with and without --codemode), code mode matched direct tool calls on reliability. It did not reduce turns or time for small coding tasks, typed agents with no tools, or a two-call lookup across 49 tools. Most turns exist because the model has to see a result before deciding the next step, and a script can’t remove that. Code mode helps when a script can do work the model doesn’t need to see:

  • Loops and aggregation. Read every file in a listing, filter records or sum fields, and return only the total.
  • Chains through typed outputs. An output schema helps the model write reliable chains by showing what each tool returns. This includes MCP tools with an outputSchema. Scripts can also chain generic outputs when the model knows their shape; otherwise, it may need a separate turn to inspect the result.
  • Large inventories. One codemode tool replaces many function descriptors. Declarations take about 10 KB per 50 tools, so the 48 KiB budget fits roughly 200 or more.

Leave it off for interactive coding, agents with a handful of tools, and tasks whose steps each depend on reading the previous result. Code mode also runs each script in a sandbox and needs an executor on platforms without Node worker threads.

await using mcp = await connectMcpServers({ docs: { url: "https://mcp.example.com/mcp" } });
const agent = defineAgent({
  id: "codemode-agent",
  model: openaiModel(),
  tools: mcp.tools,
  codemode: true,
});

For defineAgentLoop, codemode: true makes AgentCall<Registry, true>, AgentDecision<Registry, Result, true> and AgentOutcome<Registry, true> name only codemode, as in the code-mode recipe: the call input is { code } and a successful outcome’s value is a CodemodeResult. reduce receives those outcomes. Decisions that name a registered tool directly fail as unknown tools, and context.capabilities lists only codemode.

The tool’s description contains generated declarations, in registry order:

declare const tools: {
  /** Read a UTF-8 file up to 1 MiB. … */
  read(input: { path: string; startLine: number | null; maxLines: number | null }): Promise<{
    path: string;
    content: string;
    // …
  }>;
  "docs__search-pages"(input: { query: string }): Promise<{
    content: { type: string; text?: string }[];
    structuredContent?: Record<string, unknown>;
  }>;
};

Inputs come from each tool’s JSON Schema descriptor. Outputs come from the output schema’s Standard JSON Schema converter when it has one; otherwise they are unknown; MCP tools declare structuredContent from their outputSchema. Names that are not identifiers are quoted; reach them with bracket access, such as tools["docs__search-pages"]. Declarations are bounded to 48 KiB; expose fewer tools, for example with MCP tools allowlists, if the inventory exceeds it. The model-backed prompt adds code-mode guidance, including reading AGENTS.md in its own script before acting on it.

The input is { code }, up to 65,536 characters. A completed script returns { result, logs, logsTruncated }: the JSON-serialized return value (null for undefined) up to 16 KiB, and up to 200 console.* lines and 4 KiB of logs. The following become recoverable observations:

  • CODEMODE_SCRIPT_ERROR: syntax errors (including TypeScript syntax) and uncaught exceptions, with the script line and logs appended.
  • CODEMODE_TIMEOUT: the script, including calls it started without awaiting, exceeded 60 seconds. Running calls are cancelled first.
  • CODEMODE_RESULT_TOO_LARGE: the return value exceeded 16 KiB. Its tool calls already ran.
  • CODEMODE_SANDBOX_ERROR: the worker stopped, for example on memory exhaustion.

Inside a script, a tool call goes through the same preparation, admission and child execution as a direct call. A recoverable ToolError or invalid arguments (CODEMODE_INVALID_CALL) and results that cannot cross into the script as JSON (CODEMODE_INVALID_RESULT) reject the call’s promise with an Error carrying code and message, which the script may catch. Handler exceptions, limit exhaustion and cancellation stop the script and fail the run, just as for a direct call. Each script call is charged against maxCalls and admitted through the shared leaf queue, so maxConcurrency bounds Promise.all fan-out. Calls the script started but did not await settle before its result is published, and cancellation and the deadline still apply to them.

The script is logged on its call (up to 16 KiB). Each script call is a child run of that call, with item key <call id>.<n> and the usual activity log, so --trace, history and Studio show what the script did. Durable agents checkpoint the script as one call: it is replay-safe only when every reachable tool is replay-safe. Reachable tool identities, including child implementation versions, are part of the agent’s definition identity, so changing one rejects resumption of old checkpoints. Otherwise an interrupted script becomes TUBELESS_AGENT_CALL_INTERRUPTED. Nested calls are not journaled individually.

Dry runs execute scripts, and each script call follows its tool’s own dry-run policy: read, list and search run, while tools without a preview handler, such as write, edit, bash and MCP tools, are skipped. A script that awaits a skipped result fails the preview, as a direct call would. A dryRun decision source can therefore preview read-only scripts.

An executor decides where scripts run; pass one as codemode: { executor }. With codemode: true, the default createNodeCodemodeExecutor() (also exported from tubeless/agent/node) runs each script in a fresh Node or Bun worker thread with heap limits, inside a node:vm context. That context disables string code generation (eval, Function) and WebAssembly, and has no process, require, fetch or timers. Values cross into the script only as JSON text. The context is created without host-realm objects, and the worker starts with an empty environment. This is best-effort isolation, not a security boundary. Like bash, treat scripts as code running with the host’s authority. On a runtime without file-based worker threads, such as Cloudflare Workers, a script run without an executor fails the run with TUBELESS_AGENT_CODEMODE_UNAVAILABLE. There, use createWorkerLoaderCodemodeExecutor.

A custom CodemodeExecutor implements execute(execution, signal). It runs execution.code as an async function body with tools and console, forwards each tool call to execution.call(name, inputJson), and turns the resolved CodemodeReply JSON into the call’s value or a thrown Error with code. It resolves with the script’s JSON result text and console lines, or { ok: false, message } for a script error, and stops promptly when signal aborts. Tubeless keeps the deadline, cancellation, budgets, draining and failure classification for every executor. A rejection without an aborted signal becomes CODEMODE_SANDBOX_ERROR, and calls made after a stop receive CODEMODE_STOPPED. The code-mode recipe runs a scripted survey without credentials.

Define tools and an agent

Every agent, including child agents, includes read, write, edit, bash, list, and search unless its setup narrows them. Pass defaultTools to either factory to grant only some, for example defaultTools: ["read", "list", "search"] for an autonomous agent that must not write or run commands; [] grants none. Unknown names fail at definition. The optional tools object adds custom capabilities; a custom entry with a built-in name replaces that tool for this agent. The decision context advertises the merged registry, code-mode declarations list only it, and other names fail as unknown tools. AgentCall, AgentDecision, and AgentOutcome take the selection as their last type parameter (AgentCall<Registry, false, "read" | "list">) and include each custom override’s exact types. Narrow outcomes by both ok and tool before reading a tool-specific value.

Use defineTool for a handler with a description, inputSchema, outputSchema, and run(validatedInput, context). Both schemas follow Standard Schema V1. The input schema must expose Standard JSON Schema input conversion for draft-2020-12, or supply an explicit inputJsonSchema object. defineAgentLoop similarly requires a resultSchema with input conversion or resultJsonSchema. Descriptors are owned, frozen JSON data; explicit descriptors assert agreement with the validators, which remain authoritative. No schema or model SDK is a runtime dependency of Tubeless.

Tool names start with a letter and contain letters, digits, underscores, or hyphens, up to 128 characters. A call ID is a nonblank string of at most 256 characters, unique within its turn. Model data can select registered names and supply arguments; it cannot add registry entries or change harness limits.

defineAgentLoop takes id, inputSchema, resultSchema, initialState, and decide, with optional tools, reduce, dryRun, and limits. It returns a pipeline with the single step and target agent. Its exact raw input, schema transformed output, and literal ID work with ordinary fromPipeline, defineProject, and definePipelineCommand. Automatic CLI flags still require JSON Schema metadata on the agent’s input schema.

Embed an agent in a pipeline

Use fromPipeline when ordinary work needs an agent’s answer, just as for any other child pipeline. The composition recipe forwards validated parent options to a delegating agent and feeds its validated answer into a dependent report step. It uses scripted decisions so the entire example, including child agents, can run without credentials or in preview mode:

make run FILE=examples/agent-pipeline.ts ARGS='--question "red missing"'
make run FILE=examples/agent-pipeline.ts ARGS='--dry-run --question "red missing"'

Both return Summary: 3 characters; RED | 7 characters; Observed NOT_FOUND: Word unavailable. Recording the parent with --trace or --store exposes the same child-agent history as recording the agent directly. A child failure or cancellation prevents the required report step from running.

Default workspace tools

Tool Arguments Result and bounds
read path, optional startLine and maxLines UTF-8 content and line range; starts at line 1, returns up to 200 lines by default and 16 KiB. maxLines may be at most 2,000.
write path, content Creates parent directories and creates or replaces the file; returns its path and byte count.
edit path, oldText, newText Replaces one exact match; missing or ambiguous matches return a recoverable error without writing.
bash command, optional cwd and timeoutMs stdout, stderr, exit code, signal, timeout and truncation flags. Defaults to 30 seconds; maximum 5 minutes. Combined output is capped at 16 KiB.
list optional path Up to 200 directory entries with file/directory/symlink kinds and a truncation flag.
search query, optional path Case-sensitive literal search with paths and line numbers; up to 50 matches and 16 KiB of path/text content.

Workspace tools publish their actions through step progress and scoped pipeline logs. Reads, writes, and edits show file paths; bash shows a command preview; list and search show their paths and query. Completion includes line ranges, bytes, exit codes, or result counts after output validation succeeds; failure and cancellation receive explicit log entries. Previews retain at most 160 input characters and escape control characters. File contents, edit replacements, and command output stay out of these activity summaries. This works with the standard pipeline reporter, including the agent REPL’s live tree and redirected or --no-graph logs.

Turns display “Plan next action”, “Tool calls”, and “Update context”; individual calls display registered tool names. Custom handlers can add detail with context.reportProgress({ completed: 0, message: "Your action" }) and context.log, using the same reporter.

File reads, edits and writes support regular UTF-8 files up to 1 MiB; named pipes and devices are rejected. read rejects binary files. search skips binary and oversized files, .git, node_modules, and nested symlinks. During directory searches it also skips unavailable descendants, retaining other matches; skippedFiles counts unsupported files and unavailable entries, counting an unavailable directory once. Missing or inaccessible explicit roots still fail, as do cancellation and unexpected I/O errors.

List and search sort each directory in case-sensitive filename order before applying result limits or descending. Each visited directory’s entry list is read in full for sorting; search processes at most 2,000 entries and 32 directory levels. Search snippets are capped at 1,024 UTF-8 bytes per line and shift to the first match when the initial snippet omits it. A query longer than that bound can itself be clipped; truncated reports omitted text or results. Listing caps retained names at 16 KiB. Result metadata is additional to these content limits. Nullable options select defaults. Application calls may omit these options; model descriptors require them explicitly with null accepted for defaults.

Paths resolve from the invocation’s context.cwd; absolute paths are accepted. bash.cwd resolves from that same directory. These are host filesystem and shell operations using the process’s permissions and environment; cwd is not a sandbox. Filesystem and command-start failures become recoverable ToolError observations. A command’s nonzero exit or timeout is reported in its result. Cancellation remains fatal and waits for the owned command to close; on POSIX, timeout/cancellation signals the command’s process group, escalating to SIGKILL after 250 milliseconds. At that deadline, remaining output pipes are closed and the output is marked truncated. Descendants that escape the process group may survive, but cannot keep the tool waiting on inherited output pipes; process groups are not a sandbox.

All defaults use the ordinary validated tool execution path, shared budgets, concurrency limits, and tracing. Read, list, and search run in dry runs. Write, edit, and bash skip live work and produce no fabricated result; an agent turn requiring one of these skipped results fails. Put dependent filesystem changes in successive turns; calls in a batch may run concurrently. Writes and edits to the same file run one at a time within a process, so concurrent edits each apply to the latest contents. Each write or edit stages the complete contents in a sibling temporary directory, then atomically replaces the destination. Cancellation or failure before replacement preserves the original; cancellation racing with replacement can leave the complete new file. Existing symlinks are followed; write can create a missing target and its parent directories without replacing the link. Existing owner, group, and file permission bits are preserved. Atomic replacement requires write and search permissions on the destination directory, even when the file itself is writable. If staging or restoring ownership is not permitted, the tool fails without replacing the original; it does not fall back to an in-place write. Replacement creates a new inode: other hard links retain the old contents, and extended file attributes are not copied. Operations are not transactions across calls or a crash-safe durability guarantee.

The workspace recipe uses all six defaults and one custom tool to create, inspect, edit, and verify message.txt. Its decisions are scripted; its filesystem and bash operations are real:

make run FILE=examples/agent-workspace.ts ARGS='--directory .tubeless/agent-tools-demo'

The recipe creates or replaces that demo file. It is registered as workspace-agent in the example project. The existing OpenAI recipe also advertises the defaults automatically, alongside its custom text tools.

Delegate to pipelines and subagents

Use pipelineTool to register an ordinary compiled pipeline or another agent in the same tools registry. A schema-backed child needs only a description:

const tools = {
  research: pipelineTool(ResearchAgent, { description: "Research one question." }),
};

The tool uses the child’s options schema for model arguments and returns its exact finalized result. For different argument shapes or a schema-less child, supply inputSchema and mapOptions together:

const tools = {
  count: pipelineTool(CounterPipeline, {
    description: "Count a question's characters.",
    inputSchema: questionSchema,
    mapOptions: ({ question }) => ({ text: question }),
  }),
};

The harness validates arguments, maps options, validates child options, and checks child plans for the whole batch before starting any child. Each schema transform runs once. Keep mappers free of side effects. A child owns its state and registry; its finish returns a result to the parent, which can make further decisions.

Run the delegation recipe without credentials:

make run FILE=examples/agent-delegation.ts ARGS='--question "red blue"'
make run FILE=examples/agent-delegation.ts ARGS='--dry-run --question "red blue"'

The parent creates two child-agent calls, consumes their results in a later ordinary summary-pipeline call, then finishes with Summary: 3 characters; RED | 4 characters; BLUE. Each decision source is scripted; it can be replaced with an application-owned model callback.

A failed child is recoverable only when all its failures are actual handler ToolErrors. Multiple expected failures become one bounded TUBELESS_TOOL_ERRORS observation in pipeline order. Mixed failures, validation, finalization, model callbacks, and cancellation remain fatal. Child pipelines inherit dry runs and retain their own step policies; mark side effects or supply preview handlers.

Decisions and state

decide(state, context) may return either of these shapes:

{ kind: "continue", calls: [{ id: "lookup-1", tool: "lookup", input: { query: "example" } }] }
{ kind: "finish", result: "The raw result schema input" }

The return type is intentionally unknown: every decision is checked, including typed provider responses. AgentDecision<typeof tools, RawResult> is useful for typed fixtures. Empty batches, duplicate call IDs, unknown names, extra envelope or call fields, and malformed arguments fail. A decision cannot both finish and schedule calls. A valid undefined final result is distinct from a missing result property.

All arguments are validated before any handler in the batch starts. Input transformations run once; each tool output and final result is validated once. Independent calls may complete in any order, but reduce(state, outcomes) sees decision order and runs once after the entire successful batch settles. Dependencies between calls belong in a later turn.

State is owned by each invocation. Initialization and reducer commits copy and freeze finite primitives, arrays, and plain records; undefined fields are allowed. Cycles, class instances, functions, and resource handles are rejected. Keep clients and file handles in application closures. Reducers return the next state; omitting a reducer preserves state without accumulating observations.

context.turn starts at one. context.stateVersion starts at zero and advances after each accepted batch, including when the reducer is omitted. The context contains ordinary step services, immutable capability descriptions/input JSON Schemas, and the raw final-result JSON Schema. It exposes no tool handlers.

Failure, cancellation, and limits

A handler may throw new ToolError(code, message) for an expected domain failure. The reducer receives { id, tool, ok: false, error: { code, message } }, while the tool’s child run retains its failed lifecycle. An arbitrary error with the same string code is fatal. Tool errors thrown by validators or reducers are fatal too. Successful outcomes contain { id, tool, ok: true, value } with validated outputs.

Limit Default Scope
maxTurns 20 Decision invocations, including finish
maxCalls 100 Whole-batch tool/child admissions across the subtree
maxDecisions 100 Subtree decision admissions; excludes provider-internal retries
maxDepth 4 Pipeline-tool delegation edges below this agent
maxConcurrency 1 Active decision callbacks and leaf step handlers in the subtree

Limits are safe integers; maxCalls and maxDepth permit zero. A batch exceeding the remaining call budget fails before any handler starts. Admitted calls consume their allowance even if execution stops before dispatch. Finishing on the last turn succeeds; continuing on that turn fails before tools run. Exhaustion fails with TUBELESS_AGENT_LIMIT_REACHED, including bound, consumed count, requested count, and scope in the error. Invalid decisions and state use TUBELESS_AGENT_INVALID_DECISION and TUBELESS_AGENT_INVALID_STATE. Ordinary pipeline child-error wrapping retains these source codes in the cause chain.

The root invocation owns shared counters and execution permits. Child limits can tighten the bounds; every ancestor limit still applies. Concurrent sibling admissions check and charge the entire batch atomically, with no refunds. maxTurns remains local to each agent. A pipelineTool call consumes one call and one delegation edge; calls and decisions inside that child also consume ancestor budgets. Ordinary pipeline steps consume leaf permits, not call counts. A depth of zero permits handler tools but rejects pipeline tools.

Decision callbacks release their permits before child dispatch. Wrappers waiting on descendants hold no permit, so nested execution works with concurrency one. The scope survives ordinary fromPipeline, forEachPipeline, and iteration between agents. Use registered pipeline tools for delegation: manually calling run from a handler is outside this composition contract. Schema validation, mapping, reducers, and finalizers are not counted as leaf handlers. The outer pipeline’s DAG maxConcurrency remains a separate control.

Cancellation stops new calls and waits for active work to settle. Fatal failures also stop dispatch and drain active work, without cancelling sibling handlers. Neither case calls the reducer or starts another decision. Handlers must cooperate with context.signal; Tubeless cannot terminate an uncooperative promise.

Planning never initializes state or calls models, argument validators, or tools. A dry run skips the agent unless dryRun(state, context) supplies a preview decision source. Tools skip by default and require their own preview handler. A skipped required tool or agent output fails finalization instead of inventing an observation or answer.

Opt-in transcripts

Set transcript on defineAgent or defineAgentLoop to observe content in a provider-neutral format. Without it, no transcript content is copied or delivered. Capture is independent of structural tracing, --trace, --store and durable checkpoints. The application supplies the sink; Tubeless chooses no storage path.

import { defineAgent, type AgentTranscriptRecord } from "tubeless/agent";
import { openaiModel } from "tubeless/agent/openai";

const records: AgentTranscriptRecord[] = [];
const agent = defineAgent({
  id: "inspectable-agent",
  model: openaiModel(),
  transcript: {
    write: (record) => {
      records.push(record);
    },
    // Suppress instructions and task text; apply your application's policy to other events.
    redact: (event) => (event.kind === "prompt" ? null : event),
  },
});
await agent.runOrThrow({ task: "List the files in this workspace." });

Each version-1 record has agentId, runId, optional parentRunId, sequence, timestamp, turn and dryRun. runId is the owning agent invocation’s ID, which joins structural history. Reserve turn: 0 for invocation-level end events; number decision and tool events from turn one. Sequences start at one for each invocation; concurrent runs can share a sink but have separate sequences and byte budgets. An ordinary record has an event from this union:

Kind Content
prompt Task and assembled instructions for a model decision; excludes native history.
assistant One public text block from a bundled provider’s response.
decision Raw proposed decision before harness validation; includes call arguments or finish data.
call Call ID, tool name and validated tool input, including script-issued calls.
outcome Call ID, tool name, validated value or expected tool error; reused outcomes have replayed: true.
compaction Provider name after successful compaction; excludes native summary state.
end Agent invocation status: completed, failed or cancelled. Completed checkpoint replay adds replayed: true.

Custom loops emit decisions, calls, outcomes and completion. Model-backed agents also emit prompts; the bundled OpenAI and Anthropic adapters add public text and compaction markers. Capture excludes opaque conversation state, encrypted reasoning, thinking blocks, signatures and raw HTTP bodies. It describes observed actions and model output; it is not a replayable provider conversation. A recorded proposal can still fail validation. Successful tools in a subsequently failed batch remain recorded; fatal error details stay in structural history rather than transcript end events.

Content can include project guidance, file contents, scripts and tool errors. Supply redact to return a replacement event of the same kind or null to suppress it. The redactor sees an owned, frozen snapshot and cannot change runtime values or envelope identity. Tubeless checks JSON-compatible replacements for the same kind and required fields before delivery, and disables capture if those checks fail. Only finite plain JSON content is retained: cycles, accessors, class instances, undefined values and excessive depth produce { omitted: <event kind>, reason: "unsupported-content" } instead of an event.

maxRecordBytes bounds the full JSON record plus newline: default and maximum 64 KiB, minimum 1 KiB. maxBytes bounds each invocation: default 1 MiB, maximum 16 MiB, minimum twice maxRecordBytes. Content is bounded before redaction and rechecked afterwards. Oversized events produce a record-limit omission without passing the original content to the redactor. One record’s budget is reserved for { limit: "maxBytes" }; after it is written, capture stops, including the end event. An envelope that cannot fit disables capture. Omission markers contain no original content. Neither omitted content nor a missing end event establishes an empty or failed conversation.

Delivery is awaited and serialized per invocation, in observation order. Providers and tools enqueue observations during execution; the harness drains delivery outside provider request and code-mode script deadlines, including after rejected responses. Tool outcomes follow completion order; use call IDs to match decisions. Sink or redactor failure disables that invocation’s capture with a fixed warning and leaves the agent’s execution unchanged. Applications own sink I/O deadlines, permissions, retention and access. Slow writes delay the run. This is best-effort inspection; process death can leave partial records. Recovery continues to use its acknowledged checkpoint store. Completed recovery emits only an end marker for the new run, without duplicating historical content.

Child agents need their own explicit transcript option. A parent’s transcript can still contain its calls to the child and returned values. Planning and skipped dry runs emit nothing; explicitly authored previews emit records marked dryRun: true. The credential-free transcript recipe shows a caller-selected NDJSON destination and decision suppression. History and Studio currently render structural traces; transcript readers and evaluation helpers are described in the feature design.

Inspection and recordings

Plans show the bounded turn pipeline and the agent’s capability inventory and limits. Runtime call IDs are not selectable --step targets. Definition identity includes capabilities, input/result descriptor fingerprints, limits, and tool pipeline identities. Set implementationVersion when changing prompts, validators, reducers, or handlers whose semantics cannot be inferred from the graph.

Execution uses ordinary decide, calls, and reduce steps, with a new child run per turn and per dispatched tool. Turn iteration relations identify the owning agent. A tool run’s parentRunId identifies its turn, and itemKey retains the call ID. Handler-tool first-attempt trace attributes record agent.runId, agent.turn, agent.callId, agent.tool, and agent.parentAttemptId. Decision, admission, and reduction attempts record bounded decision/count/state-version summaries. Dispatch also records the selected tool name on the turn, so aliases for the same child pipeline remain distinguishable. These trace v3 records round-trip through NDJSON and SQLite. State, prompts, inputs, and outputs are not recorded automatically. Live progress keeps at most 32 visible call groups and 32 recent turn groups.

Use tubeless history <run-id> to inspect a recorded agent or a parent pipeline containing agents. The detail view joins descendants by run identity and shows agent → turn → call → child agent, including decisions, state versions before and after reduction, observed/selected call counts, cumulative local call admissions, errors, and termination. Repeated call IDs remain separate across turns; a failed tool can appear inside a completed turn when the agent recovered. History retains every recorded call even when live progress rows were truncated.

bunx tubeless run --trace agent.ndjson ./examples/agent-delegation.ts -- --question "red missing"
bunx tubeless history --trace agent.ndjson
bunx tubeless history --trace agent.ndjson <agent-run-id>
bunx tubeless history --trace agent.ndjson --json <agent-run-id>

JSON details add agentHistory.agents: each agent has its run ID, declared capabilities and limits, termination, and ordered turns with calls. Calls link to child agents with childAgentRunIds; each child has a parentCall containing the owning agent, turn, call ID, and call run ID. This keeps nested histories linked without duplicating subtrees. Each turn carries usage, each agent its own usage and a subtreeUsage that adds every descendant agent, and agentHistory.usage holds the run total; all are per provider and model, and an absent counter was not reported. Existing run lists and --events output retain their scope. --pipeline selects the requested root run; its detail still includes children with different pipeline IDs.

Older traces remain readable. Missing decisions or tool aliases are shown as unknown; history does not guess an alias from a pipeline name. A dispatch with no recorded child has unknown status. A run with no terminal event remains recorded as running, which does not establish that a process is still alive. Termination distinguishes validated finish, limit exhaustion, cancellation, failure, skipped execution, and completion without a recorded finish decision. Detailed Studio presentation remains a separate slice.

Durable execution

Add durability and an explicit implementationVersion to either agent factory. The execution key identifies one task across process restarts. Invoke the same agent with the same key and inputs to resume it. Use a new key for new work or after changing its definition, prompt, validators, reducers, model configuration, or tool implementations.

import { defineAgent } from "tubeless/agent";
import { openSqliteAgentCheckpointStore } from "tubeless/agent/node";
import { openaiModel } from "tubeless/agent/openai";

const store = await openSqliteAgentCheckpointStore(".tubeless/agents.sqlite");
try {
  const agent = defineAgent({
    id: "coding",
    implementationVersion: "coding-v1",
    model: openaiModel(),
    durability: { store, key: "issue-123" },
  });
  await agent.runOrThrow({ task: "Fix issue 123 and verify the change." });
} finally {
  store.close(); // after active invocations have settled
}

The SQLite adapter uses Node 22.18+ built-ins (node:sqlite), WAL and full synchronous commits; it adds no runtime dependency. It creates database files with mode 0600 and rejects existing files with group or other access on POSIX. Database files must be regular files with one hard link; SQLite sidecars are checked as well. On Windows, protect the database directory with the application’s filesystem ACLs. A private <database>.leases directory holds one SQLite lock file per execution key. Different keys run concurrently; a second owner of the same key is rejected. Closing the lease or exiting the process releases its native SQLite lock, so PID reuse cannot prevent recovery. Keep the lock directory and its files while any store is open; removing them can break exclusive ownership. This adapter is intended for one host with a local filesystem. It requires Node’s node:sqlite; use another store for runtimes without that module or distributed ownership.

The credential-free durable workspace recipe writes and verifies a demonstration file. Run it with Node from the repository:

bun run build
node dist/workbench/workbench-bin.js run examples/agent-durable.ts -- --task "hello durable"

It opens storage lazily, closes it after execution, and derives a key from the task. Repeating that task returns its saved answer without repeating tools. A completed answer describes the committed execution; it does not recheck external files that have since changed. Plans and dry runs open no storage. Durable resume is driven by the key, independently of the CLI’s artifact --resume flag and trace/history storage.

Before external work, the harness acknowledges decision-budget reservations, validated decisions and prepared arguments, whole-batch call reservations, and individual call intent. It commits validated outcomes independently, then the reduced state or final result. Child agents share this journal and ancestor budgets, including through ordinary pipeline composition. Live children use their parent’s journal rather than opening an independent store or execution key. Every live child joining recovery must also declare an explicit implementationVersion, even without its own durability configuration. Bump that version when changing its decisions, tools or other semantics; ancestor definitions include child versions, so changed children invalidate saved ancestor results too. Explicitly previewed children retain ephemeral state and do not require a version. Stable fan-out keys and deterministic mapping preserve child identities; ordinary pipelines are not themselves checkpointed step by step. Accepted call argument transforms, committed output transforms, and committed finish transforms are reused. Root input validation still runs on every invocation. Model usage recorded after a resume is attached to the resumed turn run, whose recorded turn metadata keeps the execution’s logical turn number rather than the new invocation’s iteration count.

Call state at restart Behavior
Completed Reuse the validated outcome; do not dispatch again.
Pending Dispatch after the saved batch admission.
Running, replay: "safe" Rerun with the same durable call identity.
Running, replay: "unsafe" Return TUBELESS_AGENT_CALL_INTERRUPTED as a recoverable outcome.

Handler tools and ordinary pipeline tools default to "unsafe". Read, list, search, and directly registered agent pipelines default to "safe". Set replay: "safe" only for repeatable work or an operation protected by business idempotency. A safe ordinary pipeline may repeat its intermediate steps and option mapping; that entire invocation must be repeatable. Custom tools and decision callbacks receive context.execution, containing the stable execution ID, agent route, turn, and optional call ID. Use all applicable fields for external idempotency keys. Trace run IDs identify individual attempts and can change after a restart.

Checkpoints cannot make arbitrary external effects exactly once. An interrupted unsafe mutation may have run partially or completely; the agent must inspect its effects and choose its next action. Unacknowledged decisions may make a new model request and consume another decision reservation. Initialization, validators, mappers and reducers must be pure: a crash before their output is acknowledged can repeat them. Cancellation preserves resumable state; ordinary fatal failures are saved as terminal failures. A rejected storage write stops the invocation even when its commit is ambiguous. Reopening reads authoritative storage before choosing recovery behavior.

Implement AgentCheckpointStore for another backend. acquire must guarantee exclusive ownership, and write must atomically replace the bytes and resolve only after durable acknowledgement. Trace exporters are best-effort and do not satisfy this contract. createMemoryAgentCheckpointStore() is useful for tests and embedded hosts; its data is lost with the process. The default plainAgentCheckpointCodec preserves finite plain data, undefined fields, negative zero and sparse arrays. Other validated arguments or results require a deterministic lossless codec; agent state itself remains owned plain data. Checkpoints contain task inputs, prompts, conversation and outputs; protect them as application data.

Execution environments

The default local environment uses the host’s filesystem and shell permissions. Supply environment to either factory to place all default workspace tools and project-guidance discovery in a different workspace:

import { defineAgent, type AgentEnvironment } from "tubeless/agent";
import { openaiModel } from "tubeless/agent/openai";

function agentForWorkspace(environment: AgentEnvironment) {
  return defineAgent({ id: "remote-coding", model: openaiModel(), environment });
}

An AgentEnvironment owns resolveCwd, projectInstructions, read, write, edit, bash, list, and search. Implement them against your sandbox or remote service using the declared typed results and cancellation signal. Environment factories, workspace resolution, guidance loading and default tools recheck cancellation before and after their work. If an operation rejects while the signal is aborted, the run retains the signal’s cancellation reason even when the adapter throws an SDK-specific error. Without an aborted signal, adapter errors keep their ordinary failure semantics. Active operations still settle before the run returns. All byte limits below count UTF-8 data and apply at the shared tool boundary:

  • Limit write.content, edit.oldText, and edit.newText to 1 MiB each. Oversized arguments fail validation before any call in the batch starts.
  • Return at most 16 KiB of read content or combined bash stdout/stderr.
  • Return up to 200 listing entries with at most 16 KiB of retained names.
  • Return up to 50 search matches with at most 1,024 bytes per snippet and 16 KiB of combined path/text content.

Oversized results fail validation before state commits. Apply truncation in the backend and set truncated when omitting text or results. Enforce file size and mutation semantics in the backend as well, including the completed file after an edit. No local guidance or filesystem fallback is used for an explicit environment. Its stable id identifies the workspace authority, and cwd identifies the workspace within it; durable resume rejects changes to either. Custom tools and decision callbacks receive context.environment. Composed children inherit it unless they declare their own environment. Custom closures remain responsible for using that authority instead of accessing the host directly.

An environment may be a capability object or an async factory. The factory is resolved once per live invocation and is never called by planning or skipped dry runs. Model agents and agents with an explicit environment resolve cwd once before decisions and tools. createNodeAgentEnvironment() from tubeless/agent/node exposes the local adapter explicitly; its default node:local id names the local filesystem authority rather than the host, so a recreated container or pod still resumes its checkpoints. Pass { id } to name a distinct authority, for example one store shared by several hosts. Environments supply capabilities, not a sandbox guarantee: isolation and remote-process termination belong to the backend. The optional Node adapter and storage remain outside the provider-independent contracts.

Run the credential-free environment recipe to list a workspace through an explicit Node adapter, or supply your own environment to its factory.