Auto-managed state agents (using
AutomaticallyManagedStateAgent) are TypeScript functions you write yourself. They execute deterministically from start to finish, with no LLM driving the control flow — though you can call LLMs as needed within your code.
The runtime handles state persistence automatically. You write a straightforward async run function, and the babel-plugin-agent-compiler transforms it into a resumable state machine behind the scenes.
Example
Add the"use agent" directive at the top of your file so the runtime can manage state between tool calls.
The
description field is optional and deprecated as of @guildai/agents-sdk 0.4.0. Guild generates the agent’s published description automatically from its code, so setting description no longer affects the published description.Input and output schemas
Define your schemas using Zod. The runtime uses them to validate input and expose the agent as a typed tool for orchestrating agents.Error handling
Any exception thrown from yourrun function is returned to the calling agent or user. Use standard TypeScript error handling:
Limitations
The"use agent" directive relies on the Babel compiler to transform your code into a state machine. This means:
- No
Promise.all,Promise.any, orPromise.race— awaiting tool calls one at a time serializes between the calls, so each runs independently. If the calls are independent tool calls and you want them to run in parallel rather than one after another, usetask.gather/task.gatherSettledinstead — see Parallel tool calls withtask.gatherbelow. These are the compiler-supported replacement forPromise.all/Promise.allSettledwhen every input is a tool call. - No dynamic function references across
awaitpoints — conditionally assigned functions may not survive serialization - No external imports — only
@guildai/agents-sdk,zod, and@guildai-services/*are supported
What may cross an await
Every await is a possible suspension point. When the agent suspends, the
state machine’s frames — which hold your local variables — are serialized with
@guildai/s11n and restored on resume. So the rule is: anything in a local
variable that crosses an await must survive that round trip.
s11n handles primitives, plain objects, arrays, Map, Set, Date, plain
Error, cycles, and shared references. It does not handle Promise, RegExp,
WeakMap, WeakSet, class instances, or arbitrary functions.
The compiler does not check this. Code that violates it type-checks, builds,
and — if the agent happens never to suspend — may pass a test run, then fail
once a real suspension happens.
A promise held in a local across an await
The hazard is holding a promise in a variable that is still live at a suspend
point. Creating and consuming one inside a single expression is fine, because
nothing promise-valued ever occupies a frame slot.
task.gather type-checks and builds clean, then fails at run time:
task.gather argument.
The failure is not diagnosable from the message, so recognize the two
signatures:
The
ReferenceError names a variable that plainly exists in your source. It
means the frame slot holding that value did not come back — not that you
misspelled anything.
Async code must live in the agent’s file
The compiler only processes the file that carries the"use agent" directive.
An async function imported from another module is never compiled into the
state machine, so it has no frames of its own and no static body the compiler
can hoist. Awaiting one across a suspension loses its state: the call compiles
cleanly, type-checks, and returns the wrong thing after a resume.
async boundaries. An orchestrator that grows large stays in one file.
What you can move out is synchronous code. A sync helper is not compiled
at all, so it may live in any file, as long as the value it returns is
serializable. That is the seam to decompose along — see
Keep heavy work in synchronous helpers
below.
The same reasoning covers function values that arrive from outside the
compiled file — a function passed in as an argument, returned from a library
call, or imported from another module. The compiler has no body to hoist, so
you may call it freely within a single step, but storing it in a variable that
crosses an await yields undefined after a resume.
What the build catches, and what it does not
Some unsupported constructs fail loudly at build time:
Others compile clean and go wrong at run time, with no warning:
For
for await, enumerate the source synchronously and await each item
inside an ordinary for-of loop. A genuinely streaming source cannot be
consumed from a compiled agent.
Keep heavy work in synchronous helpers, not async ones
The"use agent" directive makes the compiler translate every asynchronous
function in the agent’s module — the run body and any async function it
nests or calls within the same file — into state-machine steps. It does
not translate synchronous functions (those are hoisted and left as
ordinary JavaScript), and it never touches code in other files. So the lever
for heavy pure computation is simple: make it a synchronous function. The
async/sync distinction is what matters here, not where the function lives.
A synchronous call runs to completion within a single step of its caller,
which has two payoffs:
-
Nothing extra is serialized. A sync helper isn’t compiled at all — it’s
hoisted to the closure as an ordinary function and runs inside its caller’s
current step, so its intermediates — a 5,000-element array, a large
Map— are plain JS locals that never enter$framesand so are never serialized. (What is captured at eachawaitis the live frame stack of the compiled code.) Mark that same helperasyncand it becomes part of the state machine: its locals move into frame slots and serialize at every suspension like everything else. -
It runs as plain JS, and it can’t blow the step budget. Compiled code
is native JavaScript, but the compiler lowers every loop into numbered
state-machine steps and each iteration costs one metered step against a
budget (1,000 per refill). A genuine
awaitresets the refill counter, but a tight compiled loop never yields — so a few thousand iterations of pure arithmetic exhaust the budget repeatedly, and after a fixed number of refills without yielding (10 by default, i.e. ~10,000 steps) the runtime stops the agent with “Agent ran too many synchronous steps without yielding.” A sync helper sidesteps this entirely: it runs as one ordinary native loop, off the meter.
run() a thin orchestrator — tool calls, task.gather, control flow
— and push date parsing, bucketing, aggregation, statistics, and large-array
assembly into synchronous helpers (module scope is the natural home):
run()’s own loops are compiled too: each iteration spends one step
against the budget. That’s fine for tens or hundreds of items, but if the
orchestrator itself iterates over thousands, move that loop into a sync helper
as well — the budget doesn’t care whether a loop is “setup” or “work.”
The sync helpers are also independently unit-testable, since they never touch
task.
(This applies only to compiled "use agent" agents. Self-managed-state agents
— the start / onToolResults pattern — have no directive and aren’t compiled
at all.)
Compilation required for sub-agents and service hooks
Calling a sub-agent or service hook from an uncompiled auto-managed state agent crashes at runtime. To prevent this, Guild validates at build time that any auto-managed state agent using these tools is compiled. An auto-managed state agent must be compiled with the"use agent" directive when it registers or calls:
- Sub-agents (tools of
toolType: "agent"). - Individually registered integration service hooks.
ui, guild, and console — are exempt and do not require compilation.
Importing text assets
Guild compiles and bundles your TypeScript agent with esbuild during the server-side build. So that you can import bundled files as strings, the build applies a fixed text-loader whitelist: files with a whitelisted extension are read as raw text through esbuild’s text loader. You cannot override this whitelist from your localbundle script.
The server-side build loads imported files by extension:
Import a whitelisted text asset like any other module:
Parallel tool calls with task.gather
Promise.all over tool calls is an anti-pattern here: it hides the tool-call promises from the runtime and does not survive serialization (see Limitations above). For auto-managed state agents ("use agent"), the supported way to fan out independent tool calls concurrently is task.gather and task.gatherSettled. The runtime allocates every subtask atomically, dispatches them as a single batch, suspends the state machine while they run, and assembles the results in source order on resume.
One agent, one job
The same work over N inputs — process every file, fetch every PR, summarize every issue — belongs in one compiled agent that maps those inputs to tool calls in a singletask.gather. Do not create a second sub-agent just to gain concurrency: task.gather already runs the batch in parallel inside the single agent.
.map() builds the array inline in the task.gather argument, so no promise is ever stored in a local across an await — see Rules and limits.
task.gather — fail fast
Analogous to Promise.all: resolves to an array of results in source order, or rejects on the first failure.
task.gather([a, b]) returns a tuple whose elements are the awaited return types of a and b, in order.
task.gatherSettled — collect every outcome
Analogous to Promise.allSettled: never rejects. Each entry is a PromiseSettledResult describing whether that call fulfilled or rejected, so one failing call doesn’t sink the rest.
Rules and limits
- Inputs must be tool-call expressions —
task.tools.X(...), sub-agent tool calls, or hook tools. TheToolCallPromisebrand on the parameter type rejects arbitrary promises (fetch(...), timers, library code) at compile time. This is the deliberate boundary that makesgatherwork wherePromise.allcan’t: every input is a dispatchable tool call the runtime can serialize, not an opaque promise. - Build the array inline at the call site. Tool-call promises are promises, so an array of them held in a local variable is live in a frame slot when
gathersuspends, and serialization fails — see A promise held in a local across anawait. Pass an array literal in the argument. - Use only from a compiled (
"use agent") agent body. Self-managed state agents already fan out in parallel by returningcallTools([...])with more than one entry — they don’t needgather.