Customizing Agents
Assembly Line starts small, but every major runtime choice has an explicit customization point. Add the smallest file or config block that owns the behavior you need.
Context
Agents do not need context.ts. Without it, Assembly Line uses defaultContext().
The default context includes trusted instructions, the active event, bounded recent history, memory shape, file manifest, current attachments, compact skill and capability catalogs, visible tool summaries, channel metadata, and trust boundaries.
Configure the default:
import { defaultContext, defineAgent } from "@assemblyline-agents/core";
export default defineAgent({
model: "openai/gpt-5.4-mini",
context: defaultContext({
recentHistory: { maxMessages: 12 },
files: { includeManifest: true }
})
});
Extend it with custom policy:
// context.ts
import { defaultContext, defineContext } from "@assemblyline-agents/core";
export const customContext = defineContext({
kind: "custom",
name: "customContext",
extends: defaultContext({
recentHistory: { maxMessages: 5 }
}),
options: {
includeProjectMarker: true
}
});
// agent.ts
import { defineAgent } from "@assemblyline-agents/core";
import { customContext } from "./context";
export default defineAgent({
model: "openai/gpt-5.4-mini",
context: customContext
});
Context policy is trusted runtime code. It is recorded in the manifest with source attribution.
Runtime Filesystem
Every model prompt receives the stable Assembly Line filesystem contract:
/memory durable memory, writable through memory tools by default
/history read-only conversation history
/files read-only input context and attachments
/workspace writable workspace for artifacts and modified copies
/skills durable skills when self-improvement is enabled
bash starts in the workspace cwd, so prefer relative shell paths. Use absolute
logical paths such as /workspace/report.txt with the core file tools.
Sandboxes are acquired lazily. Normal channel receipt, model turns without sandbox-backed tools, skill activation, memory reads/writes, and final delivery do not need a sandbox.
Gateway Adapters
gateway.ts chooses portable runtime infrastructure:
import { adapter, defineGateway } from "@assemblyline-agents/core";
import { dockerDeploy, dockerSandbox } from "@assemblyline-agents/docker";
import { neonPostgres } from "@assemblyline-agents/postgres";
import { r2Blob } from "@assemblyline-agents/s3";
export default defineGateway({
deploy: dockerDeploy(),
runtime: adapter("node"),
state: neonPostgres(),
blob: r2Blob(),
sandbox: dockerSandbox({ image: "node:22-slim", network: "none" }),
scheduler: adapter("local")
});
Adapter choices are independent:
- Deploy target: local, Railway, Docker, Fly, and generic VPS supported.
- Runtime: Node.
- Durable state: local dev/test files or Postgres for production.
- Blob storage: local dev/test files, R2, or generic S3-compatible storage.
- Sandbox: local dev/test, Docker, Daytona, E2B, and Modal supported.
- Scheduler: local for development and static schedule dispatch.
See Adapters for helper functions and environment variables.
State Stores
Durable state is defined by capability facets rather than one monolithic
interface. RuntimeOptions.state accepts either:
- a full
StateAdapter(the intersection of every facet — whatInMemoryStateAdapter,FileStateAdapter, andPostgresStateAdapterimplement), detected by duck-typing (createRun+appendEvent); or - a
StateStoresobject that brings only the facets you can persist.
import { OpenEveRuntime, type StateStores } from "@assemblyline-agents/runtime";
const state: StateStores = {
runs: myRunStore // required: runs, events, tool calls, checkpoints,
// deliveries (incl. the durable queue), idempotency
// conversations?, schedules?, files?, usage?, sandboxSessions?, memory?, settings?
};
const runtime = new OpenEveRuntime({ manifest, state });
runs (a RunStore) is required — omitting it fails the constructor.
Optional facets are conversations (ConversationStore), schedules
(ScheduleStateStore), files (FileIndexStore), usage (UsageStore),
sandboxSessions (SandboxSessionStore), memory (MemoryStateStore), and
settings (RuntimeSettingsStore). The settings facet persists agent-level
operator settings and their control-plane audit events. If it is omitted, the
ingress kill switch works only in process and does not survive a restart.
Any facet you omit falls back to a non-durable in-memory implementation and
the runtime logs a single state.degraded warning at boot listing the
missing facets. A missing or failing usage facet is reported as degraded
observability but never blocks runtime boot, model requests, or response
delivery. Capability guards (isRunStore, isConversationStore,
isScheduleStateStore, isFileIndexStore, isUsageStore,
isSandboxSessionStore, isMemoryStateStore, isRuntimeSettingsStore,
isStateAdapter) are
exported for hosts that feature-detect adapters.
Host Hardening (Embedders)
Hosts embedding the runtime or the Node server get concurrency limits, ingress rate limiting, and graceful shutdown as configuration:
import { installSignalHandlers, listenNodeRuntime, type RateLimiterStore } from "@assemblyline-agents/node";
const handle = await listenNodeRuntime({
...runtimeOptions,
maxConcurrentRuns: 16, // RunCapacityError / HTTP 429 beyond this
maxQueuedRuns: 8, // optional wait queue before rejection
rateLimit: {
limits: {
"provider-channel": { capacity: 60, refillPerSecond: 10 },
"run-create": { capacity: 10, refillPerSecond: 1 }
}
// store?: RateLimiterStore — plug a shared (e.g. Redis-shaped) bucket
// store for multi-replica deployments; defaults to in-process memory.
// keyFor?(request) — custom bucket key; return undefined to exempt.
}
});
installSignalHandlers(handle); // SIGTERM/SIGINT -> handle.shutdown()
await handle.closed;
maxConcurrentRuns/maxQueuedRuns(orASSEMBLY_LINE_MAX_CONCURRENT_RUNS/ASSEMBLY_LINE_MAX_QUEUED_RUNS) bound brand-new execution. Accepted provider events wait in the durable per-conversation mailbox when capacity is busy; direct embedders callingruntime.run()still use the in-process admission queue and should catchRunCapacityError(it carriesretryAfterMs). Resumes always bypass the limit.rateLimitis off by default (falsedisables even the env config). TheRateLimiterStoreinterface is a single asynctake(key, { capacity, refillPerSecond, cost?, now? }), so shared stores are easy to implement.handle.shutdown({ timeoutMs? })is idempotent and drains in order:/readyz-> 503, listener + ingress, scheduler, workers,runtime.onIdle()(bounded byASSEMBLY_LINE_SHUTDOWN_TIMEOUT_MS),TelemetrySink.flush?(), thenStateAdapter.close?(). Custom state adapters and telemetry sinks can implement those optional methods to participate.runtime.hasRunCapacity()andruntime.onIdle()are public for hosts that build their own servers.
Agent Engine
Pi (@assemblyline-agents/pi) is the default primary engine, and agent.ts rejects a
harness: slot at validate time. The Node host has one built-in preview route:
an openai-codex/* model uses @assemblyline-agents/codex and the official Codex
app-server. The runtime speaks to either engine only through the internal
AgentHarness contract from @assemblyline-agents/core, which keeps continuations opaque
JSON and all durability (tool execution, approvals, checkpoints, events,
usage) runtime-owned.
Embedders and tests can still replace the engine through RuntimeOptions:
agentHarness: a singleAgentHarnessthat overrides the engine for every run — the seam the durability test suite uses to drive scripted engines. A harness implements one method,runTurn(input, ctx), and returns a response plus an opaque, versioned continuation when the runtime pauses the run.models: an optional pi model catalog forwarded topiAgentHarness({ models }).modelHarnesses: optional host-ownedprovider-prefix routes. A matching entry is selected from the persistedmodelSpec, including on approval, input, connection, and crash-recovery resumes.agentHarnesstakes precedence.@assemblyline-agents/nodeinstalls theopenai-codexroute by default.
When embedding a built agent, start with loadRuntimeBundle(artifactRoot) and
spread the returned options into OpenEveRuntime. The bundle includes the
artifactRoot module-resolution hint used for packaged dependencies. Hosts
that assemble manifest and sourceBundle manually should pass the same
optional artifactRoot explicitly.
Subagents run through Pi too. Their definitions can narrow the model,
workspace, tools, and connections, and follow-up messages resume from persisted
continuations. An inherited openai-codex/* model uses the same built-in Codex
route and CLI OAuth session as the primary agent. There is no public primary or
subagent harness selector.
Tool Discovery And Capability Metadata
Assembly Line starts model runs with core harness tools and keeps long-tail capabilities behind deferred discovery. (This tool capability: block is one of several meanings of "capability" in Assembly Line — see the disambiguation in Plugins.) Authored tools can provide capability metadata when inference is not enough:
capability: {
visibility: "deferred",
execution: "direct",
namespace: "billing",
tags: ["invoice", "customer"],
aliases: ["receivables"]
}
Visibility values:
always- visible up front.deferred- discoverable throughtool_search.skill- relevant when a skill is active.hidden- runtime/internal use.
Execution values:
direct- trusted app runtime.sandbox- isolated filesystem or shell work.both- can use more than one path.
Override, Wrap, Or Disable Built-In Tools
Every built-in harness tool (read, write, edit, delete, list, grep, bash, ask_question, spawn_subagent, load_skill, tool_search, tool_describe, tool_call) is a replaceable slot. An authored file at tools/<name>.ts with a built-in's name replaces that built-in; the model keeps seeing a tool by that name, backed by your implementation.
Wrap the default instead of rewriting it by spreading builtInToolDefaults from @assemblyline-agents/runtime:
import { defineTool } from "@assemblyline-agents/core";
import { builtInToolDefaults } from "@assemblyline-agents/runtime";
const write = builtInToolDefaults.write;
export default defineTool({
...write, // keep the default description, schema, and executor
async execute(input, ctx) {
await ctx.emit("audit.write_requested", { path: (input as { path: string }).path });
return write.execute(input, ctx);
}
});
The deferred bridge tools (load_skill, tool_search, tool_describe, tool_call) are runtime-handled and have no wrappable executor; overriding them replaces the tool wholesale.
Remove a built-in entirely with a disableTool() sentinel. The filename selects the tool, and a filename that matches no built-in fails the build instead of silently doing nothing:
// tools/bash.ts
import { disableTool } from "@assemblyline-agents/core";
export default disableTool();
Overrides and disables are ordinary manifest entries, so they version with the agent, contribute to agentRevision, and diff in evals exactly like a prompt change.
Deterministic guards
A wrapper can refuse to execute until a precondition holds, turning "the prompt asks the model to X before Y" into a rule the harness enforces. Return a structured refusal (do not throw — authored tool throws fail the run) and the model self-corrects:
import { defineTool } from "@assemblyline-agents/core";
import { builtInToolDefaults } from "@assemblyline-agents/runtime";
const write = builtInToolDefaults.write;
export default defineTool({
...write,
async execute(input, ctx) {
const validated = await ctx.memory?.read({ path: `guards/${ctx.runId}/validated.json` }).catch(() => undefined);
if (!validated) {
return { error: "Run the validate tool before writing output files." };
}
return write.execute(input, ctx);
}
});
Durable state for guards can live in ctx.memory (persists across runs) or the sandbox filesystem; key by ctx.runId for per-run ordering rules.
Per-tool runtime policy (embedders)
Hosts can gate any tool by name without touching agent sources via RuntimeOptions.coreToolPolicy:
const runtime = new OpenEveRuntime({
// ...
coreToolPolicy: { bash: "approval", write: "disabled" }
});
Modes are enabled (default), approval (forces an approval gate), and disabled (removed from the tool set; forced invocations fail). ASSEMBLY_LINE_BASH_TOOL_MODE remains the env-level shorthand for bash.
Hooks: Observe The Run Event Stream
Files under hooks/ subscribe to the durable run event stream — audit logging, metrics, persisting runs to your own database, alerting. Handlers are keyed by event type (* matches every event) and fire after each event is durably recorded, so a hook can never corrupt the stream. Hooks are observe-only: they cannot inject model context or alter execution, and a thrown handler is logged (hook.failed) and swallowed — the run continues.
// hooks/audit.ts
import { defineHook } from "@assemblyline-agents/core";
export default defineHook({
events: {
async "run.completed"(event, ctx) {
await myDatabase.recordRun(ctx.runId, event.data);
},
"tool.execution_failed"(event, ctx) {
console.warn(`[${ctx.agent.name}] tool failed in ${ctx.runId}`, event.data);
}
}
});
Hooks are distinct from instrumentation.ts: instrumentation is the telemetry export pipe (spans, sampled, truncated, redacted — right for dashboards), while hooks receive every durable event, complete and in order — right for application logic. Reach for instrumentation to watch the agent from outside; reach for a hook to do something when an event happens.
Hooks must be declared in the agent folder — a dependency can never inject one invisibly.
Per-Run Resolvers
resolvers.ts decides model, instructions, and the default tool set per run, when the right capabilities depend on who is calling, the channel, or metadata. The resolver returns a declaration; the runtime records it as a run.capabilities_resolved event before applying it, so eval traces, replay, and audits always show exactly what a run resolved to.
// resolvers.ts
import { defineRunResolver } from "@assemblyline-agents/core";
export default defineRunResolver(async (ctx) => {
if (ctx.metadata?.tier === "trial") {
return {
model: "openai/gpt-5.4-mini",
instructions: "This caller is on the trial plan; do not access billing tools.",
defaultTools: ["read", "list", "grep"]
};
}
return null; // authored defaults apply
});
Semantics:
modeloverrides the manifest model for the run; a host-forced model (RunAgentOptions.model) still wins.instructionsis appended to the authored instructions — it composes with the agent's identity rather than replacing it.defaultToolsreplaces the default-visible tool set wholesale (built-in names included), so list every tool the run should start with.- Returning
null/undefinedleaves everything at the authored defaults.
Unlike hooks, a resolver is load-bearing configuration: a resolver that throws fails the run rather than silently degrading.
Approvals And Safe Outputs
Use needsApproval when a tool has durable or external side effects:
import { approvalRequired, defineTool } from "@assemblyline-agents/core";
export default defineTool({
description: "Publish a status update.",
inputSchema: {
type: "object",
properties: { body: { type: "string" } },
required: ["body"]
},
needsApproval: approvalRequired("Publishing is externally visible.", "external"),
async execute(input: { body: string }, ctx) {
await ctx.emit("status.publish_requested", {
idempotencyKey: ctx.idempotencyKey("status")
});
return { published: true };
}
});
Use toModelOutput to keep the model-visible result smaller than the persisted tool result.
Self-Improvement
Self-improvement means one specific thing: the agent can author and edit its own skills as it learns repeatable procedures.
export default defineAgent({
id: "learning-agent",
model: "openai/gpt-5.4-mini",
selfImprovement: {
writable: true,
writeApproval: false
}
});
Compiled skills/ seed a durable skill store. Redeploys upgrade pristine seeded skills and preserve anything the agent or user changed. Set selfImprovement.writable to false for static, manifest-only skills.
Dynamic Automations
Dynamic automations are runtime-created routines. They are separate from static files in automations/.
export default defineAgent({
model: "openai/gpt-5.4-mini",
dynamicAutomations: {
dynamic: true,
approval: false
}
});
A tool can use ctx.automationManager to create, list, update, or delete time-based automations. The runtime dispatcher leases due rows and starts one durable run per row. Automated work should be idempotent.
Dynamic Connections
Dynamic connections let an agent persist runtime-provided MCP, OpenAPI, or HTTP services. They are off by default and should be allowlisted.
export default defineAgent({
model: "openai/gpt-5.4-mini",
dynamicConnections: {
dynamic: true,
approval: true,
allowedHosts: ["mcp.example.com", "api.example.com"]
}
});
Credentials must come from host APIs, authorization flows, or encrypted grant stores. Never put secrets into skills, messages, tool inputs, agent folders, or sandbox files.
Channels
Use a provider helper when one exists:
import { defineDiscordChannel } from "@assemblyline-agents/discord";
export default defineDiscordChannel();
Provider helpers verify incoming requests, normalize provider events into ChannelTurn, preserve provider delivery metadata, return fast acknowledgements when appropriate, and deliver replies through provider APIs.
Use defineChannel() when implementing a new provider boundary:
import { defineChannel } from "@assemblyline-agents/core";
export default defineChannel({
transport: "http",
route: "/message",
methods: ["POST"]
});
Raw custom HTTP routes are local/dev-friendly, but production requests need a
host auth policy or bearer auth before the runtime will use the default message
fallback. Provider-facing channels should export normalizeHttp() and perform
provider signature, token, or tenant validation there.
Channels also declare what production ingress requires and how their attachments download:
- Set
ingress: { requiredSecretEnv: [["MY_WEBHOOK_SECRET"]] }on the channel config (any-of groups of env vars). The compiler stamps it into the manifest; production boot fails until at least one group is fully set, anddevModelogs a warning instead. - Export
resolveAttachment(attachment, ctx)to turn a turn attachment into a{ url, headers, filename? }download request with your provider's credentials and host allowlist. The runtime downloads, size-caps, and stores the bytes, and only ever consults the module of the channel that produced the turn, so credentials cannot leak across channels. Returnundefinedfor a generic unauthenticated URL download.
Provider-specific parsing belongs in channel modules. Durable state, blob storage, model selection, and sandbox lifecycle belong to the runtime and adapters. For the full ChannelModule implementation contract — ingress, idempotency, delivery, and attachment resolution — see Authoring Plugins: Channel Modules.
Sandboxes
Local sandbox:
import { defineSandbox } from "@assemblyline-agents/core";
export default defineSandbox({
adapter: "local",
image: "node:22",
workingDirectory: "/workspace"
});
workingDirectory may be omitted or set to /workspace; other aliases are
invalid because hosted shell commands see a real, physical /workspace.
/runtime is retired. Local is a trusted temporary-directory emulation; use
Docker when exact local shell namespace parity matters.
Docker sandbox:
import { dockerSandbox } from "@assemblyline-agents/docker";
export default dockerSandbox({
image: "node:22-slim",
network: "none"
});
Supported hosted sandbox helpers include Daytona, E2B, and Modal. Snapshot policy is opt-in:
export default defineSandbox({
adapter: "daytona",
image: "node:22",
snapshot: {
mode: "manual",
retainLast: 3,
reason: "operator-requested checkpoint"
}
});
Observability
Assembly Line records runs, events, checkpoints, tool calls, delivery obligations, usage records, and timelines by default, and the Node runtime exposes /runs inspection endpoints for dashboards, tests, and operator tooling — all with no configuration.
To also export OpenTelemetry spans to an external backend, configure telemetry in agent/instrumentation.ts — the single home for telemetry. It is auto-discovered and run once at startup; its presence enables telemetry (there is no separate toggle). Return a sink from the setup callback. @assemblyline-agents/otlp exports OpenTelemetry GenAI spans over OTLP/HTTP to Langfuse, Phoenix, Grafana, Honeycomb, and any other OTLP backend — the backend is chosen by endpoint, with no OpenTelemetry SDK dependency.
import { defineInstrumentation } from "@assemblyline-agents/core";
import { createOtlpSinkFromEnv } from "@assemblyline-agents/otlp";
export default defineInstrumentation({
serviceName: "learning-agent",
captureContent: "usage", // "usage" (default) | "content" | "full" | "off"
setup: ({ env }) => createOtlpSinkFromEnv(env)
});
Capture detail. captureContent decides how verbose logging is, per environment. usage (default) records token/cost/model/tool metadata but no message bodies; content/full add prompt/completion and tool input/output, truncated and key-redacted by the runtime before any sink sees them. Prompts can contain secrets/PII, so keep usage in production and reserve full for trusted debugging. See the capture-detail table.
Langfuse recipe. Point the endpoint at Langfuse's OTLP ingestion and pass a Basic auth header built from your Langfuse public/secret keys:
OTEL_EXPORTER_OTLP_ENDPOINT=https://cloud.langfuse.com/api/public/otel
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <base64(public_key:secret_key)>"
createOtlpSink({ endpoint, headers, ... }) is available too when you prefer explicit options over environment variables.
Structured logs
The runtime emits structured JSON log lines (one object per line with level, time, msg, and event-specific fields) for channel ingress lifecycle, HTTP requests, security warnings, and recoverable internal failures. Control verbosity with ASSEMBLY_LINE_LOG_LEVEL (debug, info, warn, or error; default info). Hosts embedding the runtime directly can replace the logger by passing logger (a RuntimeLogger) in RuntimeOptions — for example to route logs into an existing logging pipeline — or silence it with noopLogger().