@nylorun/agents 0.19.1-beta → 0.21.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # @nylorun/agents
2
2
 
3
3
  Runtime API client package: definition authoring and a session client. The Tenant's
4
- settings, models, vaults, signing keys and application keys are the Management
4
+ settings, models, credential coverage, signing keys and application keys are the Management
5
5
  API's, through [`@nylorun/admin`](../admin/README.md). Depends only on `@nylorun/core` among Nylorun
6
6
  packages. A developer application's production tree should contain only this
7
7
  package and `@nylorun/core` from Nylorun. Vocabulary:
8
- [runtime/src/CONTEXT.md](../runtime/src/CONTEXT.md).
8
+ [runtime/server/src/CONTEXT.md](../../runtime/server/src/CONTEXT.md).
9
9
 
10
10
  ## Application entry: save your agents
11
11
 
@@ -14,12 +14,12 @@ package and `@nylorun/core` from Nylorun. Vocabulary:
14
14
  import { createClient } from "@nylorun/agents";
15
15
  import { agents } from "../agents/index.js";
16
16
 
17
- const client = createClient();
17
+ const client = await createClient();
18
18
  for (const agent of agents) await client.saveAgent(agent);
19
19
  ```
20
20
 
21
- The Runtime runs each agent from its manifest and the files saved with it (skills), and
22
- never calls your code during a session. Your services are reached as tools: [HTTP
21
+ The Runtime runs each agent entirely from its manifest and the files saved with it
22
+ (skills), so a session needs nothing from your process. Your services are reached as tools: [HTTP
23
23
  tools](#http-tools) and remote MCP servers. `saveAgent` refuses, before sending anything, an
24
24
  agent with a tool that would run your code (`tool({ run })`) or a flow agent with a tool
25
25
  stage: make it an `http()` tool or serve it from a remote MCP server. `tool({ run })` still
@@ -29,7 +29,7 @@ runs in the local engine (`@nylorun/harness/run`). `implementationVersion` defau
29
29
  `createClient()` finds the Runtime through the two `NYLORUN_*` variables or the Project link
30
30
  that `npx nylorun start` writes; with neither, it fails with `connection_missing` and names
31
31
  those steps. Action endpoints (`createActionHandler`), and before them `connectAgents` and
32
- executors, were removed; see [MIGRATION.md](../MIGRATION.md#action-endpoints-are-removed).
32
+ executors, were removed; see [MIGRATION.md](../../guides/MIGRATION.md#action-endpoints-are-removed).
33
33
 
34
34
  ## HTTP tools
35
35
 
@@ -47,15 +47,14 @@ const refundOrder = http({
47
47
  output: z.object({ refundId: z.string() }), // optional
48
48
  url: "https://billing.example.com/refunds",
49
49
  method: "POST", // default; or PUT, PATCH
50
- credential: "billing", // optional: a vault credential bound to this URL
50
+ credential: "billing", // optional: the session's Environment holds a credential for this URL
51
51
  timeoutMs: 20_000, // default 60000, at most 300000
52
- approval: "always", // optional: each call waits for session.approve()
53
52
  });
54
53
 
55
54
  export const support = Agent({ id: "support" }).tools(refundOrder);
56
55
  ```
57
56
 
58
- The tool goes into the manifest as `http` (and `approval`), and the Runtime's
57
+ The tool goes into the manifest as `http`, and the Runtime's
59
58
  Tool Gate sends the model's input as the JSON body, under the Host's address
60
59
  policy (`NYLORUN_ENDPOINT_*`), with `content-type: application/json`,
61
60
  `Nylorun-Session-Id`, `Nylorun-Turn-Id`, `Nylorun-Agent-Id` and an
@@ -63,21 +62,76 @@ policy (`NYLORUN_ENDPOINT_*`), with `content-type: application/json`,
63
62
  restart, so your service can drop duplicates. A `2xx` JSON answer is the output
64
63
  (text when it is not JSON and the tool has no `output`), checked against
65
64
  `output`. Any other status (with the start of its body), a timeout, a refused
66
- address or a mismatched output is a tool error the model sees. A call whose
67
- answer was lost with the gateway is `uncertain` and never sent again.
68
-
69
- `credential` works as for a remote MCP server: the session's attached vaults
70
- must hold a credential bound to the tool's exact URL (a `credentialSelections`
71
- entry whose `serverName` is the `credential` picks one when several are), else
72
- the call fails before it is sent (`http.credential`). A credential with `via`
65
+ address or a mismatched output is a tool error the model sees.
66
+
67
+ **How tool calls are delivered.** Each MCP or HTTP tool call the model decides
68
+ is sent at least once, and every send carries the same key: `Idempotency-Key`
69
+ for an HTTP tool, `_meta["nylorun/idempotency-key"]` on an MCP `tools/call`. A
70
+ crash, a restart or a lost connection to the gateway sends the call again with
71
+ that key, up to 3 sends; after that the model gets `tool.interrupted` ("may
72
+ have run") and decides. A tool that honors the key runs once; one that ignores
73
+ it may act twice, and `attempts` above 1 in `tool.completed` and in
74
+ `session.toolCalls()` marks where. Both also say where each call ran (`kind`:
75
+ `mcp`, `http`, `sandbox`, `platform` for the Runtime's own tools, or `harness`),
76
+ its `server`, the `skill` it ran and the `plugin` that brought its server or
77
+ skill. A sandbox command (`bash`, `write` and the
78
+ rest) is never run twice by the Runtime: a crash fails it to the model as
79
+ `tool.interrupted`. When the model decides the same call again, in a later
80
+ turn say, that is a new call with a new key: put a business key (an order or
81
+ charge id) in the tool's input schema to refuse it too.
82
+
83
+ `credential` works as for a remote MCP server: the vault of the session's
84
+ Environment must hold a credential bound to the tool's exact URL, else the call
85
+ fails before it is sent (`http.credential`). A credential with `via`
73
86
  sends the call there instead, such as a gateway, with the session owner in its
74
- identity header, and a `401` is `credential_rejected` to the model, not retried. `approval: "always"` pauses the turn for `session.approve()`; a
75
- denied call never runs and the model sees the denial. A remote MCP server takes
76
- `approval: "always"` too, for every one of its tools:
77
- `.mcp({ shop: { type: "streamable-http", url, approval: "always" } })`.
78
- An HTTP tool is also a flow stage (see [Flow agents](#flow-agents)), and runs
87
+ identity header, and a `401` is `credential_rejected` to the model, not retried.
88
+ Whether a call waits for a person is not the tool's to say: it is the Tenant's
89
+ [tool policy](#tool-policy). An HTTP tool is also a flow stage (see [Flow agents](#flow-agents)), and runs
79
90
  only on the Runtime (a local `run()` reports `http.runtime-only`).
80
91
 
92
+ ## Tool policy
93
+
94
+ Which MCP and HTTP tool calls run, wait for a person, or are refused is the
95
+ Tenant's tool policy, set by the operator on the Management API with a
96
+ management key (`PUT /v1/tenant/tool-policy`, `admin.toolPolicy.put()` in
97
+ `@nylorun/admin`). The Runtime applies it to every call of every harness as it
98
+ journals the call, before anything is sent, and its Tool Gate runs only a call
99
+ the journal holds running, so a harness cannot skip it.
100
+
101
+ ```ts
102
+ import { createAdmin } from "@nylorun/admin";
103
+
104
+ await createAdmin().toolPolicy.put({
105
+ rules: [
106
+ { effect: "ask", tool: "refund_order", reason: "Refunds need a person" },
107
+ { effect: "deny", tool: "refund_order", when: { amount: { gt: 10_000 } } },
108
+ { effect: "ask", server: "github", tool: "github__delete_*" },
109
+ ],
110
+ });
111
+ ```
112
+
113
+ A rule matches a call when every selector it sets matches: `tool` (the name the
114
+ model calls it by; `*` matches any run of characters), `server` (an MCP
115
+ server's name), `kind` (`mcp` or `http`), `agent` (the agent that calls it) and
116
+ `when` (fields of the call's input by dotted path, each tested with `equals`,
117
+ `in`, `gt`, `gte`, `lt`, `lte` or `matches`). The strictest matching rule
118
+ decides, and a call no rule matches runs:
119
+
120
+ - `deny`: the call is never sent; the model gets `tool.denied` with the rule's
121
+ `reason`.
122
+ - `ask`: the turn pauses (`turn.paused`, with the tool, its input and the
123
+ reason). `session.approve(interactionId, true)` sends that exact call;
124
+ `false` fails it to the model as `tool.denied`, unsent. Nothing holds a
125
+ process while it waits, so an approval can wait a day, or a Runtime restart.
126
+ Studio shows an approval card; AG-UI ends the run with a `tool_approval`
127
+ interrupt.
128
+ - `allow`: it runs.
129
+
130
+ Sandbox tools never cross the gate: their network side is egress policy. A
131
+ manifest no longer sets `approval` (the Runtime and `saveAgent` refuse it); a
132
+ session pinned to a manifest saved with `approval: "always"` keeps it, as an
133
+ ask, until it ends.
134
+
81
135
  ## Connection resolution
82
136
 
83
137
  `resolveConnection` / `createClient()`:
@@ -92,7 +146,7 @@ only on the Runtime (a local `run()` reports `http.runtime-only`).
92
146
  (format 0 to 2), a link without credentials, or a file that does not
93
147
  validate fails with `connection_missing`: run `npx nylorun start` in the
94
148
  project again. The Project files are read by `@nylorun/core/project`, as
95
- `@nylorun/admin`, `nylorun` and `nylo` read them.
149
+ `@nylorun/admin` and `nylorun` read them.
96
150
 
97
151
  Sources never mix. Partial environment fails with `connection_missing`. A
98
152
  Runtime serves one Tenant, so nothing names it: the `tenant` option is gone
@@ -127,12 +181,7 @@ await session.input("Look up item 123", { idempotencyKey: requestId });
127
181
  const history = await session.history();
128
182
  ```
129
183
 
130
- ```sh
131
- eval "$(npx @nylorun/cli env)"
132
- # → NYLORUN_RUNTIME_URL, NYLORUN_SERVER_KEY
133
- ```
134
-
135
- Every request sets `Nylorun-Protocol` (8) and no `Nylorun-Tenant`: the Runtime
184
+ Every request sets `Nylorun-Protocol` (14) and no `Nylorun-Tenant`: the Runtime
136
185
  serves one Tenant. Before the first authenticated
137
186
  request, `Transport` fetches `/health` once, checks protocol compatibility, and
138
187
  throws `IncompatibleRuntimeError` / `incompatible_host` when the Host range or
@@ -152,7 +201,7 @@ const assistant = Agent({ id: "assistant", name: "Order assistant" })
152
201
 
153
202
  Each subdirectory under the catalog must contain a `SKILL.md` with YAML frontmatter (`name`, `description`) per [Agent Skills](https://agentskills.io/home). Every file of a skill's folder, binary included (not `.git/`, `node_modules/`, OS files like `.DS_Store`, or `.env` and `.env.*` files, which hold secrets), is part of the definition: the manifest names each one by path and SHA-256 (`skills.<name>.files`), at most 500 files of 10 MiB each. `saveAgent` uploads the files the Runtime does not hold (`client.files`, `PUT /v1/files/sha256:<hex>`) before it puts the definition, which the Runtime refuses while it names a file it lacks (`400 definition_files_missing`).
154
203
 
155
- The Runtime serves the skills itself, with no call to your process: `load_skill` returns a skill's `SKILL.md` body and the paths of its other files, and `read_skill_resource` returns a text file. A session with a sandbox also has each skill's files read-only under `/skills/<name>/`, so the model can run a skill's scripts with `bash`. Run without a Runtime, the skill tools only say so.
204
+ The Runtime serves the skills itself, with no call to your process. When a session pins the agent, the Runtime adds the skill catalog to the instructions and two tools to the manifest's `runtime` block: `load_skill` returns a skill's `SKILL.md` body and the paths of its other files, and `read_skill_resource` returns a text file. A session with a sandbox also has each skill's files read-only under `/skills/<name>@<hash>/`, so the model can run a skill's scripts with `bash`.
156
205
 
157
206
  Declare MCP servers with `.mcp(...)` (same map shape as agent-plugins `mcpServers`):
158
207
 
@@ -166,44 +215,76 @@ const assistant = Agent({ id: "assistant", name: "Assistant" })
166
215
  });
167
216
  ```
168
217
 
169
- Each key names a server; its `name` defaults to the key and, when given, must equal it. Repeated `.mcp()` calls add servers to the same capability. Nylorun accepts remote servers only: `streamable-http` and `sse` ([Agent Plugins MCP servers](https://agent-plugins.org/plugin-authors/mcp-servers)); a `stdio` server is refused, so run it behind an HTTP transport and declare its URL. Attach an Agent Plugin package with `.plugin(path)`: its skills and remote MCP servers join the agent, and a stdio server in its `mcp.json` throws.
218
+ Each key names a server; its `name` defaults to the key and, when given, must equal it. Repeated `.mcp()` calls add servers to the manifest's `mcpServers`. Nylorun accepts remote servers only: `streamable-http` and `sse` ([Agent Plugins MCP servers](https://agent-plugins.org/plugin-authors/mcp-servers)); a `stdio` server is refused, so run it behind an HTTP transport and declare its URL. Attach an Agent Plugin package with `.plugin(path)`: its skills and remote MCP servers join the agent's `skills` and `mcpServers`, each labelled `plugin: { name, version }` from its `plugin.json`, and a stdio server in its `mcp.json` throws. Names are unique across the agent's tools, skills and servers.
170
219
 
171
- Set a server's tools one by one with `tools`, keyed by the server's own tool names, `"*"` for the rest: `{ "*": { enabled: false }, search_issues: { enabled: true }, create_issue: { enabled: true, approval: "always" } }` is an allowlist with one approval. When an agent's MCP tools would fill more than a tenth of the model's context window, the Runtime defers them: the model finds them with `tool_search` and runs them with `tool_call`. `deferred: true` or `false`, on a tool or on the server, decides instead. Either setting makes the manifest v6 (see MIGRATION.md).
220
+ Set a server's tools one by one with `tools`, keyed by the server's own tool names, `"*"` for the rest: `{ "*": { enabled: false }, search_issues: { enabled: true }, create_issue: { enabled: true } }` is an allowlist (whether a call waits for a person is the [tool policy](#tool-policy)). When an agent's MCP tools would fill more than a tenth of the model's context window, the Runtime defers them: the model finds them with `tool_search` and runs them with `tool_call`. `deferred: true` or `false`, on a tool or on the server, decides instead. Either setting makes the manifest v6 (see MIGRATION.md).
172
221
 
173
- A manifest never carries a server's credential, and its `headers` hold no secret. The operator gives the server's URL a vault credential (a token or a header map, or a gateway's key with `via` and an identity header for servers that need each person's sign-in), and the session's attached vaults supply it: [DEPLOYMENT.md](../DEPLOYMENT.md#mcp-servers-and-http-tools), which also shows `nylorun mcp inspect` for listing a server's tools first. The model knows each tool as `server__tool`, with characters outside `[A-Za-z0-9_-]` replaced by `_`; a failed call reaches it with a code (`credential_rejected` on a `401`, `mcp.unreachable`, …), and a result past 32 KiB, or an image, becomes an artifact of the session that it reads with `read_artifact`.
222
+ A manifest never carries a server's credential, and its `headers` hold no secret. You write a credential for the server's URL into an Environment (a token or a header map, or a gateway's key with `via` and an identity header for servers that need each person's sign-in, with `client.environments.secrets.put`), and sessions that name the Environment use it: [DEPLOYMENT.md](../../guides/DEPLOYMENT.md#mcp-servers-and-http-tools), which also shows `nylorun mcp inspect` for listing a server's tools first. After `saveAgent`, `admin.coverage({ agentId, environmentId })` in `@nylorun/admin` says which credential each server and HTTP tool would get in that Environment ([Checking an agent's credentials](../../guides/DEPLOYMENT.md#checking-an-agents-credentials)). The model knows each tool as `server__tool`, with characters outside `[A-Za-z0-9_-]` replaced by `_`; a failed call reaches it with a code (`credential_rejected` on a `401`, `mcp.unreachable`, …), and a result past 32 KiB, or an image, becomes an artifact of the session that it reads with `read_artifact`.
174
223
 
175
- Give a session a sandbox when you open it. The agent declares nothing, so the same agent runs with or without one, in any Tenant:
224
+ A session runs in an **Environment**: one sandbox and one vault, with an id you choose, which outlives the sessions that name it (protocol 11). It is made from one version of an **environment manifest** or from inline fields, and never changes what it runs after that (protocol 14). The agent names no Environment, so the same agent runs in any Tenant; it may state what it needs (`.requires()`). Whether an Environment serves one session, one person or a project is your app's choice:
176
225
 
177
226
  ```ts
178
- const session = await client.createSession({
179
- agentId: "analyst",
180
- ownerUserId,
181
- sandbox: {
182
- network: { allow: ["pypi.org", "files.pythonhosted.org"] }, // no egress unless you list hosts
183
- resources: { cpus: 2, memory: "2GiB" },
184
- },
227
+ import { environmentManifest } from "@nylorun/agents";
228
+
229
+ // A kind of Environment, saved like an agent: a named, versioned definition (or a JSON file with $schema).
230
+ const analystBox = environmentManifest({
231
+ name: "analyst-box",
232
+ kind: "pod",
233
+ image: "python:3.13-slim",
234
+ network: { allowedHosts: ["pypi.org", "files.pythonhosted.org", "api.github.com"] },
235
+ setup: [{ command: "pip install pandas" }],
236
+ secrets: { GH_TOKEN: { type: "environment_secret", allowedHosts: ["api.github.com"], required: true } },
237
+ });
238
+ const plan = await client.environments.manifests.plan(analystBox); // changes, errors, warnings, Environments behind
239
+ await client.environments.manifests.save(analystBox, { ifMatch: plan.from ?? 0 });
240
+
241
+ // One per person or project, made from the manifest's latest version and pinned to it.
242
+ await client.environments.put("users/alice/analyst", { manifest: "analyst-box", ownerUserId: "alice" });
243
+ await client.environments.secrets.put("users/alice/analyst", "GH_TOKEN", { value: process.env.ALICE_GITHUB_TOKEN! });
244
+
245
+ // Or from inline fields: get-or-create by id, write its secrets, open sessions on it.
246
+ await client.environments.put("users/alice/web", {
247
+ kind: "virtual", // the default; "pod" for a container with its own volume, "none" for a vault without compute
248
+ network: { allowedHosts: ["pypi.org", "files.pythonhosted.org"] }, // no egress unless you list hosts
249
+ ownerUserId: "alice", // the owner guard: only alice's sessions may name it
250
+ labels: { team: "data" },
251
+ lifecycle: { deleteAfter: "30d" }, // deleted once unused this long
252
+ });
253
+ await client.environments.secrets.put("users/alice/web", "GH_TOKEN", {
254
+ type: "environment_secret",
255
+ value: process.env.ALICE_GITHUB_TOKEN!,
256
+ allowedHosts: ["api.github.com"],
257
+ });
258
+ const session = await client.createSession({ agentId: "analyst", ownerUserId: "alice", environment: "users/alice/web" });
259
+
260
+ // One per session: created with the session (id `sessions/<session id>`), deleted by release().
261
+ const { session: scratch, release } = await client.environments.forSession({
262
+ session: { agentId: "analyst", ownerUserId: "alice" },
263
+ recipe: { network: { allowedHosts: ["pypi.org"] } },
185
264
  });
265
+ await release();
186
266
  ```
187
267
 
188
- The model gets `bash`, `read`, `write`, `edit`, `grep` and `glob` on a persistent `/workspace`. These tools run in the Runtime, not in your process. The Runtime decides where the sandbox runs: today an emulated shell in the Runtime process, which is not a VM boundary and takes no `image`.
268
+ With a sandbox (kind `virtual` or `pod`), the model gets `bash`, `read`, `write`, `edit`, `grep` and `glob` on a persistent `/workspace`. These tools run in the Runtime, not in your process. A `virtual` Environment is an emulated shell in the Runtime's harness, which is not a VM boundary and takes no `image`; a `pod` runs `image` with its own volume (`storage`) once the Tenant has sandbox pods (`nylorun local sandbox enable`). Kind `none` has a vault and no sandbox, for agents that use only remote MCP servers and HTTP tools. A session that names no Environment has no sandbox and no credentials.
189
269
 
190
- `sandbox` takes `false` for none, `{ id }` to attach a sandbox resource (below), or an inline sandbox as above; omit it for the Tenant's default. `{ session }`, sharing another session's sandbox, still works and is deprecated. The Runtime checks it against the Tenant's limits (`GET`/`PUT /v1/tenant/sandbox`: a network ceiling, a resource maximum, the idle timeout) and answers `400` with every problem it finds. A caller acting for a user (`app.as(...)`) can't define one inline; it gets the Tenant's default or `false`. Private networks, loopback, the host and cloud metadata endpoints are always blocked.
270
+ An Environment never changes what it runs (protocol 14, its view's `manifest`): a `put` on an existing one may change its `labels` and owner guard, fields that match are accepted (so get-or-create stays one call), and anything else is `409 environment_immutable`. A save of a new manifest version changes no Environment: make a new one, which can start from the old one's disk (`from`). The Runtime checks what an Environment runs against the Tenant's limits (`GET`/`PUT /v1/tenant/sandbox`: a network ceiling, a resource maximum, the idle timeout) and answers `400` with every problem it finds. `lifecycle.idle` stops a pod after that long without a turn (at most the Tenant's); `lifecycle.deleteAfter` deletes the Environment, its vault and its files once nothing has used it that long. Private networks, loopback, the host and cloud metadata endpoints are always blocked.
191
271
 
192
- A sandbox can also be a resource with its own id, which outlives the sessions attached to it (Host feature `sandboxes`). Whether it serves one session, one person or a project is your choice:
272
+ Secrets are write-only: `client.environments.secrets.put(id, name, secret)` creates or replaces one, and `list`, `get` and `delete` never return a value. An `environment_secret` (`{ value, allowedHosts, inject? }`) shows its name to commands as the sentinel `nylorun-managed`, and egress-gate sets its header on HTTPS requests to `allowedHosts` (pods only); an `environment_variable` (`{ value }`) is a plain variable; `bearer` (`{ url, value, via?, identity? }`) and `headers` (`{ url, headers, via?, identity? }`) are credentials for the MCP servers and HTTP tools at `url`. A second secret for one host, or a second credential for one URL, is `409 credential_conflict` at the write ([DEPLOYMENT.md](../../guides/DEPLOYMENT.md#credentials)). When what an Environment runs declares a secret's slot (a manifest's `secrets`), send only `{ value }` (or `{ headers }`): the type and binding come from the slot, and a name it does not declare is `409 secret_not_in_manifest`. An agent built with `.requires({ sandbox: "pod", secrets: ["GH_TOKEN"] })` opens a session only on an Environment that has them (`409 environment_unsuitable`). `saveAgent(agent, { dryRun: true })` checks a definition and saves nothing.
273
+
274
+ Sessions on one Environment 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. Stop, reset, snapshot and restore wait until no turn runs (`409 environment_busy`). Deleting a session only detaches it. With `ownerUserId` set, a session of another owner is `403 environment_owner_mismatch` (a caller acting for a person gets the `404` of a missing Environment), checked when it opens and at every turn start. `client.environments.list({ labels })`, `page`, `get(id)`, `events(id, { types, since, until })` (its lifecycle, and `environment.egress` for every connection its sandbox opens, naming the turns that were running), `stop(id)`, `reset(id)` and `delete(id)` manage them, and `npx nylorun local sandbox ls | rm` does the same on a local Tenant. Acting for a person (`app.as(...)`), changing an Environment or its secrets needs `environments:write`. A trusted issuer's token reaches only the Environments its issuer grants (the identity file's `environments` templates, exact ids or prefixes ending in `/*`), checked at every turn start. The Tenant holds at most `limits.environments` of them (`PUT /v1/tenant/sandbox`, default 100).
275
+
276
+ A pod's disk can start prepared: `files` copies versions of Tenant-wide artifacts in, then `setup` runs its commands, once per new disk and before its first turn (a failed command fails the pod, and the next turn start runs the setup again). Between turns, `client.environments.snapshot(id)` keeps a version of the disk, `restore(id, version)` puts one back (a pod on a new volume seeded before its next turn), `put(newId, { from: { environment: id, version: "latest" } })` starts a new Environment from it, and `versions(id)` and `deleteVersion(id, n)` manage them ([DEPLOYMENT.md](../../guides/DEPLOYMENT.md#environments)):
193
277
 
194
278
  ```ts
195
- // One per session: created with the session, deleted by release().
196
- const { session, release } = await client.sandboxes.forSession({
197
- session: { agentId: "analyst", ownerUserId },
198
- spec: { network: { allow: ["pypi.org"] } },
279
+ await client.environments.put("projects/acme", {
280
+ kind: "pod",
281
+ files: [{ artifactId: repoTarball.artifactId, version: 1, path: "/workspace/app" }],
282
+ setup: [{ command: "npm ci", cwd: "/workspace/app", timeout: "15m" }],
199
283
  });
200
- // One per person or project: get-or-create by id, then attach sessions to it.
201
- await client.sandboxes.ensure("team-a/proj-42", { labels: { project: "acme" } });
202
- await client.createSession({ agentId: "analyst", ownerUserId, sandbox: { id: "team-a/proj-42" } });
284
+ const { version } = await client.environments.snapshot("projects/acme");
285
+ await client.environments.put("projects/acme-try", { kind: "pod", from: { environment: "projects/acme", version } });
203
286
  ```
204
287
 
205
- Sessions attached to one sandbox share its `/workspace`, and one turn runs in it at a time: a second session's turn is refused with `409 sandbox_busy` until the first ends. Deleting a session only detaches it. A spec is fixed once the sandbox exists; `labels` can change. `client.sandboxes.list({ labels })`, `get(id)` and `delete(id)` manage them, and `npx nylorun sandbox ls | rm` does the same on a local Tenant. A trusted issuer's token reaches only the sandboxes its issuer grants (the identity file's `sandboxes` templates, exact ids or prefixes ending in `/*`), checked at every turn start; creating and deleting through one needs `sandboxes:write`. The Tenant holds at most `limits.sandboxes` of them (`PUT /v1/tenant/sandbox`, default 100).
206
-
207
288
  Add an agent with `.subagents()` to let the model delegate to it:
208
289
 
209
290
  ```ts
@@ -243,9 +324,9 @@ session like any agent: put it in `export const agents`, `saveAgent(flowAgent)`,
243
324
  (`kind: "workflow"`, `workflowSchemaVersion: 3`) that embeds the agents it runs, so one
244
325
  document and one manifest hash cover the whole flow; its agents are not listed on their own.
245
326
 
246
- A flow runs no code of yours: the first stage gets the flow's input, and every later stage
247
- gets the previous stage's output. So each agent returns, through its `.output()` schema,
248
- what the next stage needs.
327
+ A flow passes data between its stages itself: the first stage gets the flow's input, and
328
+ every later stage gets the previous stage's output. So each agent returns, through its
329
+ `.output()` schema, what the next stage needs.
249
330
 
250
331
  ```ts
251
332
  import { Agent, VerdictSchema, http } from "@nylorun/agents";
@@ -284,28 +365,9 @@ export const shipFeature = Agent({ id: "ship-feature" })
284
365
  export const agents = [shipFeature];
285
366
  ```
286
367
 
287
- A flow stage cannot take `approval` yet (`flow.approval-unsupported`). To have a person
288
- approve opening the PR, give the HTTP tool to an agent as the last stage instead: that
289
- agent's turn pauses until the call is approved, and the flow session's `pending()` lists
290
- the wait:
291
-
292
- ```ts
293
- const prOpener = Agent({ id: "pr-opener" })
294
- .instructions("Open one pull request with open_pr from the summaries you are given.")
295
- .tools(
296
- http({
297
- name: "open_pr",
298
- description: "Open a pull request with the given summaries.",
299
- input: z.object({ summaries: z.array(z.string()) }),
300
- url: "https://ci.example.com/pull-requests",
301
- credential: "github",
302
- approval: "always",
303
- }),
304
- );
305
-
306
- // …
307
- .pipe(prWriter, prOpener);
308
- ```
368
+ The Tenant's [tool policy](#tool-policy) decides a flow's HTTP stages as it decides an
369
+ agent's HTTP tool calls: an `ask` rule on `open_pr` pauses the flow's turn until a person
370
+ approves the call, and the flow session's `pending()` lists the wait.
309
371
 
310
372
  | Stage | Role |
311
373
  | --- | --- |
@@ -329,12 +391,11 @@ body. Its input is the previous output, which must match the tool's `input` (an
329
391
  object); a mismatch the build can see, such as an agent with no `.output()` before it,
330
392
  is refused (`flow.input-mismatch`), and any other fails the stage when it runs
331
393
  (`tool.invalid-input`). The Runtime makes the request as for an agent's HTTP tool, with
332
- `Nylorun-Agent-Id` set to the flow agent's id and `credential` taken from the flow
333
- session's vaults; its answer is the next stage's input. A failure status, a timeout or
394
+ `Nylorun-Agent-Id` set to the flow agent's id and `credential` taken from the
395
+ vault of the flow session's Environment; its answer is the next stage's input. A failure status, a timeout or
334
396
  an answer that does not match `output` fails the stage with its code (`http.status`,
335
397
  `http.timeout`, `tool.invalid-output`, …). A Loop body that starts with an HTTP stage
336
- is retried with the Loop's input, not the feedback. `approval: "always"` is not
337
- supported on a flow stage yet (`flow.approval-unsupported`).
398
+ is retried with the Loop's input, not the feedback.
338
399
 
339
400
  A Loop may be judged by your service instead of an agent: `http({ url, method?,
340
401
  credential?, timeoutMs? })`, with no name or input, is an HTTP verifier. The Runtime
@@ -374,21 +435,21 @@ nested flow agent's id in front of its own agents'. Control stages add nothing,
374
435
  wrapping a step in `.loop()` or moving it between cases keeps its session. An agent
375
436
  may appear once per flow; use it again under a new id with `writer.withId("final-writer")`
376
437
  (`flow.duplicate-leaf`). A code tool stage (`.pipe(tool({ run }))`) runs only in the local
377
- engine: the Runtime runs no code of yours, so `saveAgent` refuses a flow agent with one. Use
378
- an HTTP stage instead.
438
+ engine: the Runtime runs flows from their manifests alone, so `saveAgent` refuses a flow
439
+ agent with one. Use an HTTP stage instead.
379
440
 
380
- The agents in a flow share one sandbox: open the flow's session with it,
381
- `createSession({ …, sandbox: { … } })`, and every agent in the flow uses it. Share it
382
- with other sessions by opening them all on one sandbox resource
383
- (`sandbox: { id }`, see `client.sandboxes`), and call
441
+ The agents in a flow share one Environment: open the flow's session on it,
442
+ `createSession({ …, environment: id })`, and every linked session in the flow
443
+ inherits it; turns within one flow do not block each other. Other sessions share
444
+ it by naming the same id (see `client.environments`). Call the sandbox
384
445
  built-ins via `session.sandbox` (application).
385
446
  Observe with `session.observe({ follow: true })` to merge linked agent streams;
386
447
  `pending()` lists waits across the tree. Studio renders the manifest tree and
387
448
  live node status. Source examples:
388
449
  `examples/agents/{chain,switch,parallel,map,loop,ship-feature}/` (outside the
389
450
  default release registry). Host contract:
390
- [HOST_CONTRACT.md](../harness/HOST_CONTRACT.md). Vocabulary (turn loop vs
391
- workflow Loop): [harness/src/CONTEXT.md](../harness/src/CONTEXT.md).
451
+ [HOST_CONTRACT.md](../../runtime/harness/HOST_CONTRACT.md). Vocabulary (turn loop vs
452
+ workflow Loop): [runtime/harness/src/CONTEXT.md](../../runtime/harness/src/CONTEXT.md).
392
453
 
393
454
  Use `session.observe({ cursor, signal })` for resumable canonical events, `session.inspect()` for waiting/uncertain state, and `approve`, `respond`, or `cancel` with an explicit stable idempotency key. Retry the same semantic command with the same key. `ownerUserId` must come from trusted server authentication. Application credentials are not browser credentials; browser applications need an authorized backend. Definition authoring is browser-bundleable.
394
455
 
@@ -400,8 +461,8 @@ the Tenant.
400
461
 
401
462
  The Runtime makes a lost HTTP tool call `uncertain` instead of repeating its external effect.
402
463
  In the local engine (`@nylorun/harness/run`), `tool({ run })` tools receive state, info,
403
- identity, resume, signal, approval/response helpers and memoized `step`. Arbitrary middleware
404
- closures are rejected for durable definitions. Agent definitions have no `.run()`; explicit
464
+ identity, resume, signal, approval/response helpers and memoized `step`. Agent definitions
465
+ have no `.run()`; explicit
405
466
  local execution is available through `@nylorun/harness/run`.
406
467
 
407
468
  ## AG-UI
@@ -446,8 +507,8 @@ createServer(toNodeListener(agui)).listen(3000);
446
507
 
447
508
  `run`, `history`, `reattach` and `cancel` are also on the handler for your own
448
509
  routing. The default `client` is `createClient()`; pass `client` to use another
449
- one, and `session(subject, agentId)` to add the person's `vaultIds`,
450
- `credentialSelections` or `info`. They apply when the thread's session is
510
+ one, and `session(subject, agentId)` to add the person's `environment` (their
511
+ Environment's id: its sandbox and its vault's credentials) or `info`. They apply when the thread's session is
451
512
  created, on its first run; later runs keep them. Whatever the browser sends in
452
513
  `forwardedProps.nylorun` is replaced.
453
514
 
@@ -471,7 +532,7 @@ created, on its first run; later runs keep them. Whatever the browser sends in
471
532
  package itself: the Runtime does the protocol work.
472
533
 
473
534
  A complete web backend with a test is in
474
- [examples/src/ag-ui](../examples/README.md#an-agent-in-your-web-app-ag-ui).
535
+ [examples/src/ag-ui](../../examples/README.md#an-agent-in-your-web-app-ag-ui).
475
536
 
476
537
  Limitations: assistant text arrives once per model step (no token streaming);
477
538
  no reasoning, state, activity or subagent events, and an agent used as a tool
@@ -537,7 +598,7 @@ What the Runtime answers (Host feature `a2a-endpoint`):
537
598
  (`UnsupportedOperationError`; the card says `streaming: false`), push
538
599
  notifications, the extended card, and approvals. An agent that pauses for an
539
600
  approval shows `INPUT_REQUIRED`, refuses a reply, and can be canceled, so do
540
- not publish agents with approval-gated tools yet.
601
+ not publish agents whose tools the Tenant's tool policy asks about yet.
541
602
  - The handler calls the Runtime as the partner's subject with the scope
542
603
  `sessions:own` only, so one partner never reaches another's tasks. It sends
543
604
  no header the partner chose except `A2A-Version` and `A2A-Extensions`. Without
@@ -571,6 +632,29 @@ refuse a larger upload with `413 limit_exceeded`, and nothing is stored. Acting
571
632
  a person (`as()`, or a trusted issuer's token), a client reaches only the artifacts of that
572
633
  person's sessions, and an upload names one of them.
573
634
 
635
+ Hosts advertising `artifact-reads` also support bounded metadata pages, newest
636
+ first. `artifacts.page()` defaults to 50 rows (1–200), checks feature discovery,
637
+ and returns `{ artifacts, nextCursor }`. Pass the same filters on every page:
638
+
639
+ ```ts
640
+ const filters = { kind: "file" as const, labels: { team: "design" } };
641
+ let cursor: string | undefined;
642
+ do {
643
+ const page = await client.artifacts.page({ ...filters, cursor });
644
+ for (const artifact of page.artifacts) console.log(artifact.name, artifact.latestVersion);
645
+ cursor = page.nextCursor ?? undefined;
646
+ } while (cursor);
647
+ ```
648
+
649
+ Omit `sessionId` for all reachable artifacts, including Tenant-owned artifacts
650
+ when using an application key as itself. With `sessionId`, only that session's
651
+ artifacts are included; acting for a person also applies ownership and agent
652
+ grants on every page. Every label must match exactly. Pages omit version histories
653
+ and bytes: use `get()` and the existing version/download methods for those.
654
+ Cursors bind to the Tenant and filters. Pages are independent reads; refresh from
655
+ the beginning for newer artifacts. The existing unbounded `list()` keeps its
656
+ oldest-first behavior and does not require `artifact-reads`.
657
+
574
658
  ### Outputs: folder artifacts
575
659
 
576
660
  At the end of each turn, the Runtime exports the files the agent wrote into
@@ -601,7 +685,7 @@ keeps its application key to itself and names the person on each call:
601
685
  ```ts
602
686
  import { createClient } from "@nylorun/agents";
603
687
 
604
- const app = createClient(); // the application key, on the server only
688
+ const app = await createClient(); // the application key, on the server only
605
689
 
606
690
  // Per request, after your own sign-in:
607
691
  const person = app.as(`app:${user.id}`, { scopes: ["sessions:own"] });
@@ -621,14 +705,15 @@ compatibility check, so calling `as()` per request is cheap.
621
705
  | `sessions:own` | The person's own sessions: create, list, read, stream, message, approve, respond, cancel; and their artifacts |
622
706
  | `agents:read` | Listing the Tenant's agents |
623
707
  | `agents:write` | Saving agents; listing agents |
708
+ | `environments:write` | Creating, changing, stopping, resetting and deleting Environments, and writing their secrets (reading them takes `sessions:own` or this) |
624
709
 
625
710
  No scope reaches the sandbox tool routes; call them without `as()`. `tenant:settings` is retired (protocol 8): it grants
626
- nothing, and no subject reaches the Management API (`/v1/tenant/*`). Vaults are
627
- the installation's, created through the Management API with a management key
628
- (`admin.vaults.create({ scope: "installation", … })` in
629
- [`@nylorun/admin`](../admin/README.md)); a session attaches them with
630
- `vaultIds`, and a person's own keys go in their user vault
631
- ([DEPLOYMENT.md](../DEPLOYMENT.md#credentials)). A subject is 1–200 visible ASCII
711
+ nothing, and no subject reaches the Management API (`/v1/tenant/*`).
712
+ `sandboxes:write` (protocol 10) is refused with a message naming
713
+ `environments:write`. Credentials live in Environments: the server writes a
714
+ person's own keys into their Environment (with `ownerUserId` set to them) and
715
+ names it when it opens their sessions
716
+ ([DEPLOYMENT.md](../../guides/DEPLOYMENT.md#credentials)). A subject is 1–200 visible ASCII
632
717
  characters (spaces only inside) and `host` is reserved. Your server must drop
633
718
  any `Nylorun-*` header its own clients send, and only an application key can act
634
719
  for a subject.
@@ -638,14 +723,14 @@ The Tenant's signing keys sign capability links and run tokens.
638
723
  needed). Listing, rotating
639
724
  and revoking them is management: `admin.signingKeys.list()`, `.rotate()`
640
725
  (`{ force: true }` for incidents) and `.revoke(kid)` in `@nylorun/admin`, or
641
- `nylo access signing-keys …` from the terminal.
726
+ `nylorun tenant signing-keys …` from the terminal.
642
727
 
643
728
  ## In the browser
644
729
 
645
730
  A web page or an app calls the Runtime directly with the JWT your identity
646
731
  provider gave the person: list the provider in the Runtime's identity file
647
- ([Trusted issuers](../DEPLOYMENT.md#trusted-issuers)), and the Runtime takes the
648
- token's subject, scopes, agents and sandbox grants from it. Nylorun mints no
732
+ ([Trusted issuers](../../guides/DEPLOYMENT.md#trusted-issuers)), and the Runtime takes the
733
+ token's subject, scopes, agents and Environment grants from it. Nylorun mints no
649
734
  token and ships no browser client: use your provider's SDK for sign-in and send
650
735
  the token as `Authorization: Bearer <token>` with `Nylorun-Protocol`, through a
651
736
  reverse proxy that answers CORS (the Runtime sends no CORS headers). An expired
@@ -653,7 +738,7 @@ token answers `401` with `code: "token_expired"`; `GET /v1/me` shows what a toke
653
738
  renders to. For a chat UI, give `@ag-ui/client`'s `HttpAgent` a `fetch` that adds
654
739
  those headers.
655
740
 
656
- The SDK depends only on core within the Nylorun packages; installing it does not install harness or any AG-UI or A2A package. Use `/define`, `/client`, `/ag-ui` or `/a2a` for focused imports, or the root for convenience. Studio uses `/client`. See [the adopted host contract](../harness/HOST_CONTRACT.md).
741
+ The SDK depends only on core within the Nylorun packages; installing it does not install harness or any AG-UI or A2A package. Use `/define`, `/client`, `/ag-ui` or `/a2a` for focused imports, or the root for convenience. Studio uses `/client`. See [the adopted host contract](../../runtime/harness/HOST_CONTRACT.md).
657
742
 
658
743
  ## Embedding Studio (desktop apps)
659
744
 
@@ -11,12 +11,12 @@
11
11
  import { type SubjectScope } from "@nylorun/core/contracts";
12
12
  import type { BuiltWorkflow } from "@nylorun/core/define";
13
13
  import { type AgentSource, type AgentsClient } from "../client.js";
14
- /** Per-session parameters the host may add, such as the person's vault for MCP servers. */
14
+ /** Per-session parameters the host may add, such as the person's Environment. */
15
15
  export interface AgUiSessionOptions {
16
16
  /** Set when the thread's session is created, on its first run; later runs keep them. */
17
17
  info?: Record<string, unknown>;
18
- vaultIds?: readonly string[];
19
- credentialSelections?: Parameters<AgentsClient["createSession"]>[0]["credentialSelections"];
18
+ /** The Environment the thread's session runs in: its sandbox and its vault's credentials. */
19
+ environment?: string;
20
20
  }
21
21
  export interface AgUiHandlerOptions {
22
22
  /** Agents this endpoint may run: built agents, workflows or their ids. Nothing else is reachable. */
@@ -29,7 +29,7 @@ export interface AgUiHandlerOptions {
29
29
  client?: AgentsClient | Promise<AgentsClient>;
30
30
  /** What each subject may do through this endpoint. Default `["sessions:own"]`. */
31
31
  scopes?: readonly SubjectScope[];
32
- /** Optional per-session parameters, e.g. the person's vault for connected accounts. */
32
+ /** Optional per-session parameters, e.g. the person's Environment with their keys. */
33
33
  session?(subject: string, agentId: string): AgUiSessionOptions | Promise<AgUiSessionOptions>;
34
34
  }
35
35
  export interface AgUiHandler {
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Acting for a person (`client.as`, or a trusted issuer's token), only the artifacts of their own sessions.
13
13
  */
14
- import type { ArtifactDiff, ArtifactLink, ArtifactTree, ArtifactView, DeleteArtifactResponse, UploadArtifactResponse } from "@nylorun/core/contracts";
14
+ import { type ArtifactKind, type ArtifactPage, type ArtifactDiff, type ArtifactLink, type ArtifactTree, type ArtifactView, type DeleteArtifactResponse, type UploadArtifactResponse } from "@nylorun/core/contracts";
15
15
  import { type Transport } from "./http.js";
16
16
  /** What an upload sends: bytes, a `Blob` or `File`, text, or a stream of bytes. */
17
17
  export type ArtifactBody = Uint8Array | ArrayBuffer | Blob | string | ReadableStream<Uint8Array>;
@@ -25,6 +25,17 @@ export interface UploadArtifactOptions {
25
25
  readonly labels?: Readonly<Record<string, string>>;
26
26
  readonly signal?: AbortSignal;
27
27
  }
28
+ export interface ArtifactPageOptions {
29
+ /** Only this session's artifacts; excludes Tenant-owned artifacts. */
30
+ readonly sessionId?: string;
31
+ readonly kind?: ArtifactKind;
32
+ /** Every label must match exactly. */
33
+ readonly labels?: Readonly<Record<string, string>>;
34
+ /** Default 50, from 1 to 200. */
35
+ readonly limit?: number;
36
+ readonly cursor?: string;
37
+ readonly signal?: AbortSignal;
38
+ }
28
39
  /** A capability link, with the absolute URL to open on this Runtime. */
29
40
  export type ArtifactLinkWithUrl = ArtifactLink & {
30
41
  readonly url: string;
@@ -48,6 +59,8 @@ export declare class ArtifactsClient {
48
59
  sessionId?: string;
49
60
  signal?: AbortSignal;
50
61
  }): Promise<ArtifactView[]>;
62
+ /** Newest first, with metadata only (Host feature `artifact-reads`). */
63
+ page(options?: ArtifactPageOptions): Promise<ArtifactPage>;
51
64
  /** The artifact, with every version. */
52
65
  get(artifactId: string, options?: {
53
66
  signal?: AbortSignal;
package/dist/artifacts.js CHANGED
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Artifacts (protocol 6): `client.artifacts`. A file is uploaded in one streamed request and
3
+ * kept as an artifact with numbered versions; a session's artifacts appear in its history as
4
+ * `artifact.*` events. Download through the Runtime (with Range), or mint a capability link: a
5
+ * short-lived URL that needs no credential, for `<img>` tags and sharing. A message names an
6
+ * artifact in a file part (`session.inputParts`), and the model reads it.
7
+ *
8
+ * Folders (F8.2): at the end of each turn the Runtime exports `/workspace/outputs` of the
9
+ * session's sandbox as a version of its `outputs` folder. Read one as a tree, one file by path,
10
+ * a diff between versions or a zip.
11
+ *
12
+ * Acting for a person (`client.as`, or a trusted issuer's token), only the artifacts of their own sessions.
13
+ */
14
+ import { ArtifactPageSchema, } from "@nylorun/core/contracts";
1
15
  import { segment } from "./http.js";
2
16
  function contentTypeOf(body, declared) {
3
17
  if (declared)
@@ -46,6 +60,20 @@ export class ArtifactsClient {
46
60
  const reply = await this.transport.json(`/v1/artifacts${query}`, "GET", undefined, options.signal);
47
61
  return reply.artifacts;
48
62
  }
63
+ /** Newest first, with metadata only (Host feature `artifact-reads`). */
64
+ async page(options = {}) {
65
+ await this.transport.requireFeature("artifact-reads", options.signal);
66
+ const query = new URLSearchParams({ limit: String(options.limit ?? 50) });
67
+ if (options.cursor !== undefined)
68
+ query.set("cursor", options.cursor);
69
+ if (options.sessionId !== undefined)
70
+ query.set("sessionId", options.sessionId);
71
+ if (options.kind !== undefined)
72
+ query.set("kind", options.kind);
73
+ for (const [key, value] of Object.entries(options.labels ?? {}))
74
+ query.append("label", `${key}=${value}`);
75
+ return ArtifactPageSchema.parse(await this.transport.json(`/v1/artifacts?${query}`, "GET", undefined, options.signal));
76
+ }
49
77
  /** The artifact, with every version. */
50
78
  get(artifactId, options = {}) {
51
79
  return this.transport.json(`/v1/artifacts/${segment(artifactId)}`, "GET", undefined, options.signal);