Agents
Make the Agent your sessions run on from a saved manifest, and move it to new versions in place.
import { Agent } from "@nylorun/agents";
const analyst = Agent({ id: "analyst" })
.instructions("Analyse the data you are given with pandas, and show your numbers.")
.build();
await client.agentManifests.save({
agent: analyst,
runtime: { kind: "virtual", network: { allowedHosts: ["pypi.org", "files.pythonhosted.org"] } },
});
const agentId = `users/${user.id}/analyst`;
await client.agents.put(agentId, { manifest: "analyst", owner: user.id });
const session = await client.createSession({ agent: agentId, ownerUserId: user.id });An Agent is the live instance of one version of an
agent manifest: one sandbox of the manifest's
kind, one set of secrets for its shell, MCP servers,
and HTTP tools, a disk, its memory, its
triggers, and the sessions that run on it. Your app picks
the id and decides what it serves: one session, one person
(users/<id>/...), or one project (projects/acme). An id is up to 200
characters of /-separated segments of letters, digits, ., _, and -.
The manifest names no Agent, so one manifest makes as many Agents as you need.
Making an Agent opens no session. Every session names its Agent with agent
when it opens, and keeps it for the session's life.
Make an Agent
put creates the Agent from a saved manifest, or finds the one with that id,
in one call:
| Field | Meaning |
|---|---|
manifest | Required. "analyst" tracks the manifest: its latest version now, and each version saved later. "analyst@3" pins version 3. An unknown name or version is 404 manifest_not_found. |
owner | The owner guard. null clears it; left out, it stays as it is. |
labels | Key-value pairs to filter on, at most 32 by default. A put that sends them replaces them. |
from | On create only: { agent, version }, start the disk from another Agent's disk version. |
The Runtime resolves the version's runtime section against the Tenant's
limits and answers 400 with every problem it finds. The Tenant holds at most
100 Agents by default (409 limit_exceeded; see
Kubernetes), and a pod Agent on a
Runtime without sandbox pods is 409 agent_unavailable. Request and response
shapes are on
Create, find, or move an Agent.
On an existing Agent, put changes labels and owner, and moves the Agent
to the version manifest names (see Versions). The same put
works as get-or-create: with the bare name, a tracking Agent moves to the
latest version it can run, and stays where it is when it cannot. A bare name
on a pinned Agent makes it track again. Another manifest's name is
409 agent_fixed_field: fork the Agent instead.
Kinds
The kind is the manifest's runtime.kind:
| Kind | Behaviour |
|---|---|
virtual | An emulated shell and filesystem in the harness's container, with /workspace kept by the Tenant. Not an isolation boundary. Takes no image, storage, setup, or files; allowedHosts takes exact host names only. |
pod | Preview. A container with its own volume on the Tenant's cluster, after npx nylorun local sandbox enable --context <ctx>. See Kubernetes. |
none | No compute: secrets only, for an agent whose tools are remote MCP servers and HTTP tools. No sandbox tools and no disk. A manifest with no runtime section makes this kind. |
With a sandbox (virtual or pod), our harness gives the model bash,
read, write, edit, grep, and glob on a persistent /workspace, and
save_artifact. A custom harness offers its own.
Shell credentials reach pods only:
a virtual Agent gets their variables but no header.
npx nylorun doctor sandboxdoctor sandbox reports the sandbox backend in use and the Tenant's limits.
Versions
An Agent runs one version of its manifest at a time, resolvedVersion in its
view, and either tracks the manifest or pins a version (tracking). It moves
between versions in place, keeping its id, disk, secrets, memory, and sessions:
- A
putthat names another version moves it:"analyst@4"pins version 4,"analyst"tracks the latest. - A save moves every Agent that tracks the manifest. A pinned Agent stays.
- Each session's next turn runs the version the Agent runs then. A turn already running ends on its version. A flow session keeps the version it opened on.
Check a move first, then make it:
const plan = await client.agents.plan("projects/acme", { manifest: "analyst@4" });
console.log(plan.applies, plan.changes, plan.missingSecrets, plan.refusals);
const agent = await client.agents.put("projects/acme", { manifest: "analyst@4" });
console.log(agent.resolvedVersion, agent.targetVersion, agent.waitingFor);plan() changes nothing (PUT /v1/agents/{id}?dryRun=true). Its applies
says when the version takes effect:
applies | When |
|---|---|
none | The Agent already runs that version. |
now | Turns that start from now run it: a harness section change, any change on a virtual Agent or one of kind none, or a pod change to fields other than those below. |
next_start | A pod's image, resources, network, setup, or files changed: its pod's next start applies them, on the same volume. Until then the view shows targetVersion and waitingFor: "start", and turns run resolvedVersion. |
secrets | The version adds a required secret slot with no value (missingSecrets). It applies once you write the value; until then waitingFor: "secrets" and the state is input_required. |
refused | The version changes what is fixed for the Agent's life (refusals). |
The plan also lists the changes by section, dormantSecrets (secrets the
version does not declare, kept and unused while it runs), and how many
sessions take the version at their next turn.
client.agents.restart(id) applies a version a pod waits to start on now: a
running pod stops, and the next turn starts it on the new version; a stopped
one starts at once. A start that fails (its setup, the pod, or a start that
takes too long) goes back to the version the Agent ran, with its disk as it
is: the view's failedVersion names it, the Agent gets an agent.start_failed
event, and its state is failing. A tracking Agent does not move to that
version again on its own; restart(id, { retry: true }) tries it again.
Request and response shapes are on
Restart an Agent on its pending version.
The manifest's name, runtime.kind, harness.id, and runtime.storage are
fixed for the Agent's life. A put that would change one is
409 agent_fixed_field, with each in details.fields; a save leaves a
tracking Agent it cannot move on its version. To change them, fork.
npx nylorun agents put projects/acme --manifest analyst@4 --dry-run
npx nylorun agents pin projects/acme 4 # move to version 4 and stay there
npx nylorun agents track projects/acme # move to the latest, and with each save
npx nylorun agents restart projects/acme # --retry: the version that failed to start
npx nylorun agents list --outdated # Agents moving to another versionFork
A fork makes a new Agent from a manifest version, whose disk starts from the source's and whose secrets are copied inside the Runtime. It is how to change what is fixed for an Agent's life, or to keep a copy. Check the plan first:
const forkPlan = await client.agents.planFork("projects/acme", {
id: "projects/acme-gpu",
manifest: "analyst-gpu",
});
console.log(forkPlan.disk, forkPlan.secrets.copied, forkPlan.secrets.missing, forkPlan.changes);
const { agent } = await client.agents.fork("projects/acme", {
id: "projects/acme-gpu",
manifest: "analyst-gpu",
});| Option | Meaning |
|---|---|
id | The new Agent's id. An id that exists is 409. |
manifest | "name" (tracking) or "name@n" (pinned). Default: the source's manifest, tracking its latest version. |
disk | "latest" (default): snapshot the source's disk and start from it, refused with 409 agent_busy while a turn runs. A disk version number, or "empty". |
secrets | "copy" (default): copy the secrets the new manifest declares, or every secret when it declares none. "none": copy none. Values never leave the Runtime. |
labels, owner | The new Agent's labels and owner guard. Neither is copied from the source. |
planFork() makes nothing and answers the plan: the disk it starts from,
secrets.copied, secrets.dropped (left behind), secrets.missing (required
slots still missing), the changes between what the two run, whether setup
runs, how many sessions stay on the source, and how many memories it
copies. Memories are copied as they are, each at version 1, with no history.
fork() answers the new Agent and the plan it ran.
The source keeps what it runs, its disk, and its sessions, and gets an
agent.forked event. The new Agent's view has forkedFrom:
{ agent, disk }. A session's Agent is fixed, so open new sessions on the new
id when you are ready. On a pod, a fork onto other setup commands runs them on
the copied disk before its first turn, and a fork pulls the image afresh.
Request and response shapes are on
Fork an Agent.
npx nylorun agents fork projects/acme projects/acme-gpu --manifest analyst-gpu --dry-runState
The view's state is the first of these that holds: paused, failing,
input_required, working, or sleeping.
state | Holds when |
|---|---|
failing | Its pod failed or was lost, a start on a new version failed, or a session's latest turn failed. |
input_required | A session waits on a person (an approval, an answer, or an uncertain call), a move waits for a required secret, or a required secret of the version it runs has no value. |
working | A turn runs. |
sleeping | Nothing to do. |
attention lists every state that holds, highest first, each with its
reason and the sessionId behind it. A required secret slot
with no value holds input_required until you write it, since a tool that
uses it would fail when called:
{ state: "input_required", reason: "secret_unset: required secret ORDERS_TOKEN unset" }. compute.state is its sandbox's:
ready, creating, running, or stopped. nextWakeAt is when its next
trigger fires. Accept values you do not know in each.
const waiting = await client.agents.list({ state: "input_required" });
for (const agent of waiting) console.log(agent.id, agent.attention);Shared disks and turns
Sessions on one Agent share its /workspace and run their turns at the same
time, one turn per session. Which sessions work on one disk, and when, is your
app's call: give work that must not share files its own Agent, or a fork. A
flow's child sessions run on its Agent. Sandbox commands of different sessions
run at the same time; each session's commands keep their order. A skill's
files sit in a folder per version, /skills/<name>@<hash>/, so agents with
different versions of one skill share an Agent.
Stop, restart, reset, snapshot, restore, and delete wait for the Agent's turns:
while any turn runs they answer 409 agent_busy, as does a turn start while a
snapshot or restore copies the disk. Retry after Retry-After.
Deleting a session only detaches it from its Agent. Deleting the Agent leaves
its sessions' history readable and refuses their next turn
(409 agent_unavailable) until an Agent with that id exists again.
Owner guard
With owner set, only sessions with that owner may run on the Agent. It is
checked when a session opens and at every turn start:
| Caller | Another owner's session gets |
|---|---|
| Application key | 403 agent_owner_mismatch |
Acting for a person (client.as(), a trusted issuer's token) | The 404 of a missing Agent |
Set the guard on every Agent that holds a person's own token. Every session on an Agent gets all its secrets.
Access
An application key reaches every Agent. Acting for a person with
client.as(), reading an Agent takes
sessions:own, agents:read, or agents:write, and making, moving, forking,
stopping, restarting, resetting, or deleting one, or writing its secrets or
memories, takes agents:write. The view then lists only that person's
sessions.
A trusted issuer's token reaches only the ids its issuer grants in the identity
file, such as agents: ["users/{sub}/*"]. A token that no longer reaches a
session's Agent gets 403 agent_not_granted at the turn start.
One Agent per session
For a session that needs a sandbox and secrets of its own, make an Agent named after the session, open the session on it, and delete it when you are done:
import { randomUUID } from "node:crypto";
const sessionId = randomUUID();
const scratch = `sessions/${sessionId}`;
await client.agents.put(scratch, { manifest: "analyst", owner: user.id });
// The manifest declares the secret slot "linear".
await client.agents.secrets.put(scratch, "linear", { value: process.env.LINEAR_TOKEN! });
const session = await client.createSession({ id: sessionId, agent: scratch, ownerUserId: user.id });
// ... run turns ...
await client.agents.delete(scratch);delete removes the Agent with its secrets, disk, disk versions, and memories.
Lifecycle
| Field | Meaning |
|---|---|
lifecycle.idle | Stop a pod after this long without a turn. At most the Tenant's idle limit (default 15m), which is also the default. |
lifecycle.deleteAfter | Delete the Agent, its secrets, and its disk once unused this long: no turn, change, secret write, stop, or reset. Default never. |
Both are fields of the manifest's runtime section. client.agents is the
Agent calls of the client (AgentInstancesClient; the whole client is
AgentsClient):
| Call | Does |
|---|---|
get(id) | The view: manifest, resolvedVersion, manifestHash, tracking, targetVersion, waitingFor, failedVersion, kind, harness, owner, labels, state, attention, nextWakeAt, compute, runtime (what it runs, resolved), limits, slots, disk, forkedFrom, lastUsedAt, and the sessions on it. |
list({ labels, state, manifest, outdated }) | Every Agent with all those labels, in that state, made from that manifest, or moving to another version. |
page({ ..., limit, cursor }) | The same, a page at a time. |
stop(id) | Pods: stop compute and keep the volume. The next turn starts it again. |
restart(id, { retry }) | Apply a version it waits to start on. See Versions. |
reset(id) | Pods: a new pod on a new, empty volume, with its setup and input files. The way out of agent_lost. |
delete(id) | Delete the Agent, its secrets, its disk, its disk versions, and its memories. |
events(id, { from, types, since, until }) | Its events. See Events. |
npx nylorun agents list, get <id>, and rm <id> list, read, and delete
Agents from a terminal.
Images by digest
The first time a pod of an Agent is ready, the Runtime records the image it
pulled by digest, compute.pod.imageDigest in the view. Every later pod of
that Agent, after a stop, a relaunch, or a reset, runs that digest: a tag that
moves never changes what an Agent runs. A version that changes image pulls
the new one at the pod's next start, and a fork pulls the tag afresh. An image
loaded onto the node without a registry pins nothing.
Disk versions
Keep a version of an Agent's disk between turns, and put one back:
const { version } = await client.agents.snapshot("projects/acme");
await client.agents.restore("projects/acme", version);
await client.agents.put("projects/acme-try", {
manifest: "analyst",
from: { agent: "projects/acme", version },
});| Call | Does |
|---|---|
snapshot(id) | Keep /workspace as a new version. An unchanged disk makes none (unchanged: true). |
restore(id, version) | Put a version (or "latest") back: a virtual Agent at once (done), a pod on a new volume before its next turn (pending). |
put(newId, { manifest, from }) | Create an Agent whose disk starts as another's version. Its secrets start empty. To copy secrets too, fork. |
versions(id) | The versions, newest first, and the folder artifact that holds them. |
deleteVersion(id, n) | Delete one version. |
A snapshot or restore during a turn is 409 agent_busy. A version holds files
with their modes and times, links, and empty directories, at most 200,000
entries and 10 GiB by default, within the Tenant's artifact limits; a
manifest's runtime.limits.disk may set less. Read a version's tree, files,
and diffs through client.artifacts with the artifactId
from versions(id). A pod whose volume is lost and that has a version starts
again from the latest one. Kind none has no disk.
Egress records
Each connection a pod opens to the internet, allowed or refused, is one
agent.egress event on the Agent's own events. It carries the host, port,
outcome (allowed or refused), the reason for a refusal, bytes up and
down, the duration, mode (credential for a host a
shell credential is bound to, else
tunnel), and runningTurns: the { sessionId, turnId } of each turn running
when it opened, at most 16. It never carries a header value, a path, or a
body. Session logs and turn traces hold no egress.
A connection is the Agent's: with several turns running, it names them all. To see the connections around one turn, read the Agent's egress events in the turn's time window:
const connections = await client.agents.events("projects/acme", {
types: ["agent.egress"],
since: "2026-10-15T09:00:00Z",
until: "2026-10-15T09:05:00Z",
});npx nylorun agents events projects/acme --types agent.egress --since 2026-10-15T09:00:00ZEvents
client.agents.events(id) reads the Agent's events (AGENT_EVENT_TYPES):
| Events | When |
|---|---|
agent.created, .attached, .detached, .deleted, .forked | Made (with the manifest version and forkedFrom), a session attached or detached, deleted, forked. |
agent.running, .suspended, .relaunched, .lost, .reset, .failed | Pods: the pod's life. A failed setup command is an agent.failed with its exit code and the end of its output. |
agent.outdated, .version_changed, .start_failed | A move waits (waitingFor), applies, or a start on the new version went back. See Versions. |
agent.egress | A connection. See Egress records. |
agent.run, .trigger_paused, .trigger_resumed, .trigger_secret_rotated | Triggers: a run and what came of it, and changes to a trigger. |
agent.memory_updated | A turn changed the Agent's memory. |
types keeps only those types; since and until keep the events recorded
in that window. A session's own log records agent.attached,
environment.state, and environment.exec. See Events.
Request and response shapes are on
Read an Agent's events.