@sapiom/agent-core 0.9.1 → 0.9.3
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/CHANGELOG.md +39 -0
- package/dist/cjs/scaffold.js +2 -2
- package/dist/esm/scaffold.js +2 -2
- package/dist/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +7 -5
- package/skills/sapiom-agent-authoring/SKILL.md +408 -0
- package/skills/sapiom-sandbox-preview/SKILL.md +96 -0
- package/templates/coding-pause/AGENTS.md +2 -2
- package/templates/coding-pause/README.md +2 -2
- package/templates/default/AGENTS.md +2 -2
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sapiom-agent-authoring
|
|
3
|
+
description: Build, test, and deploy a Sapiom agent — a controlled, multi-step,
|
|
4
|
+
deployable automation defined in TypeScript. Use when the user wants to
|
|
5
|
+
automate a multi-step, scheduled, recurring, or deployable task ("build an
|
|
6
|
+
agent that checks competitor prices every morning", "automate our weekly
|
|
7
|
+
report", "make a bot that reviews PRs"), or names Sapiom, defineAgent,
|
|
8
|
+
@sapiom/agent, or the sapiom-dev MCP. Also use to run, inspect, or resume an
|
|
9
|
+
existing Sapiom agent. Do NOT use for a single one-off capability call
|
|
10
|
+
(a web search, one scrape, one image) with no automation to keep — use
|
|
11
|
+
Sapiom's remote MCP or SDK for that.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Building Sapiom Agents
|
|
15
|
+
|
|
16
|
+
A Sapiom **agent** is a small TypeScript project you author with your coding agent: a
|
|
17
|
+
`defineAgent({ name, entry, steps })` where each step's `run(input, ctx)` does work — calling
|
|
18
|
+
paid Sapiom capabilities through the typed `ctx.sapiom.*` client — and returns a directive.
|
|
19
|
+
You test it locally for free, then deploy it to run on Sapiom's cloud: on demand, on a
|
|
20
|
+
schedule, or resumed by signals. All from the terminal; no dashboard required.
|
|
21
|
+
|
|
22
|
+
**Load this skill before scaffolding — it drives the whole lifecycle from zero.** Inside a
|
|
23
|
+
scaffolded project, `AGENTS.md` is the quick reference; this skill is the deep guide.
|
|
24
|
+
|
|
25
|
+
## If the Sapiom dev MCP isn't connected yet
|
|
26
|
+
|
|
27
|
+
The lifecycle below runs through the **sapiom-dev** MCP server (`@sapiom/mcp`). If its tools
|
|
28
|
+
(`sapiom_authenticate`, `sapiom_dev_agents_*`) aren't available, add the server first:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude mcp add sapiom -- npx -y @sapiom/mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Other clients: run `npx -y @sapiom/mcp` as a local (stdio) MCP server — see
|
|
35
|
+
[docs.sapiom.ai](https://docs.sapiom.ai/integration/mcp-servers/setup) for per-client config.
|
|
36
|
+
|
|
37
|
+
## Lifecycle from Zero
|
|
38
|
+
|
|
39
|
+
### 1. Authenticate (required before deploy/run)
|
|
40
|
+
|
|
41
|
+
Run `sapiom_authenticate` — it opens a browser login and caches an API key in
|
|
42
|
+
`~/.sapiom/credentials.json`. Confirm with `sapiom_status`. This makes your coding agent an
|
|
43
|
+
API-key principal; deployed agents inherit that authority to call paid capabilities.
|
|
44
|
+
|
|
45
|
+
### 2. Scaffold
|
|
46
|
+
|
|
47
|
+
Call `sapiom_dev_agents_scaffold` with a target directory. The scaffold writes:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
my-agent/
|
|
51
|
+
├── index.ts # your agent definition (edit this)
|
|
52
|
+
├── AGENTS.md # quick in-project reference
|
|
53
|
+
├── CLAUDE.md # points your coding agent at AGENTS.md
|
|
54
|
+
├── .claude/skills/ # this skill, locally — the deep guide
|
|
55
|
+
├── .sapiom-dev/stubs.json
|
|
56
|
+
├── package.json / tsconfig.json / ...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Install deps: `npm install`. Templates: `"default"` (minimal two-step starter) or
|
|
60
|
+
`"coding-pause"` (launch + pause/resume coding-model pattern).
|
|
61
|
+
|
|
62
|
+
### 3. Write steps → typecheck → check → run_local → deploy
|
|
63
|
+
|
|
64
|
+
| Command | What it does |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `npm run typecheck` | Confirms types compile and every `ctx.sapiom.*` call exists |
|
|
67
|
+
| `sapiom_dev_agents_check` | Bundles `index.ts` + validates the step graph (offline, instant) |
|
|
68
|
+
| `sapiom_dev_agents_run_local` | Runs real step code with all capabilities stubbed — free, no spend |
|
|
69
|
+
|
|
70
|
+
Then ship:
|
|
71
|
+
|
|
72
|
+
| Command | What it does |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `sapiom_dev_agents_link` | Registers the agent under your tenant |
|
|
75
|
+
| `sapiom_dev_agents_deploy` | Builds and deploys to Sapiom's cloud |
|
|
76
|
+
| `sapiom_dev_agents_run` | Starts a real (billed) execution |
|
|
77
|
+
| `sapiom_dev_agents_inspect` | Watch an execution's status, steps, and spend |
|
|
78
|
+
|
|
79
|
+
## The Step Model — Hard Rules
|
|
80
|
+
|
|
81
|
+
### Canonical API
|
|
82
|
+
|
|
83
|
+
| Import | From |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `defineAgent` | `@sapiom/agent` |
|
|
86
|
+
| `defineStep` | `@sapiom/agent` |
|
|
87
|
+
| `goto / terminate / fail / retry / pauseUntilSignal` | `@sapiom/agent` |
|
|
88
|
+
| `AgentExecutionContext` | `@sapiom/agent` |
|
|
89
|
+
| `CODING_RESULT_SIGNAL / CodingResultPayload` | `@sapiom/tools` |
|
|
90
|
+
|
|
91
|
+
`@sapiom/agent` is the only authoring package.
|
|
92
|
+
|
|
93
|
+
### `defineAgent` shape
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
export const agent = defineAgent({
|
|
97
|
+
name: "my-agent", // string — used for logging and inspect
|
|
98
|
+
entry: "start", // must name a key in steps
|
|
99
|
+
steps: { start, finish },
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Export exactly one `defineAgent(...)` from `index.ts`.
|
|
104
|
+
|
|
105
|
+
### `defineStep` fields
|
|
106
|
+
|
|
107
|
+
| Field | Type | Required | Notes |
|
|
108
|
+
|---|---|---|---|
|
|
109
|
+
| `name` | `string` | yes | Step's id; must match its key in the steps object |
|
|
110
|
+
| `next` | `readonly string[]` | yes | Step names this step may `goto`. Empty array if terminal |
|
|
111
|
+
| `terminal` | `boolean` | no | `true` if this step ends the agent's execution |
|
|
112
|
+
| `canFail` | `boolean` | no | Must be `true` to return `fail()` |
|
|
113
|
+
| `pause` | `{ signal, resumeStep }` | no | Required when returning `pauseUntilSignal(...)` |
|
|
114
|
+
| `inputSchema` | `ZodType` | no | Zod schema validating this step's input |
|
|
115
|
+
| `timeoutMs` | `number` | no | Per-step timeout; no automatic retry cap |
|
|
116
|
+
| `run(input, ctx)` | `async function` | yes | Returns a directive |
|
|
117
|
+
|
|
118
|
+
Import Zod via the `zod/v4` subpath — `import { z } from "zod/v4"` — to match the SDK's
|
|
119
|
+
schema types. Don't import from bare `"zod"` or add a second zod dependency.
|
|
120
|
+
|
|
121
|
+
### Directives — what `run` must return
|
|
122
|
+
|
|
123
|
+
| Directive | Function | Constraint |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `goto(target, output?)` | Advance to another step | `target` must be in `next[]` |
|
|
126
|
+
| `terminate(output?, opts?)` | End the execution successfully | Step must have `terminal: true` |
|
|
127
|
+
| `fail(reason?, opts?)` | End the execution as failed | Step must have `canFail: true` |
|
|
128
|
+
| `retry(opts?)` | Re-run this step | Bound with `ctx.attempts` — no automatic cap |
|
|
129
|
+
| `pauseUntilSignal(handle, opts?)` | Suspend until a signal fires | Step must declare `pause: { signal, resumeStep }` |
|
|
130
|
+
|
|
131
|
+
TypeScript enforces these constraints at compile time — a `terminate` in a non-terminal step,
|
|
132
|
+
or a `fail` without `canFail: true`, is a type error.
|
|
133
|
+
|
|
134
|
+
### Minimal two-step example
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { defineAgent, defineStep, goto, terminate } from "@sapiom/agent";
|
|
138
|
+
|
|
139
|
+
const start = defineStep({
|
|
140
|
+
name: "start",
|
|
141
|
+
next: ["finish"],
|
|
142
|
+
async run(input: { name: string }, ctx) {
|
|
143
|
+
ctx.logger.info("got input", { name: input.name });
|
|
144
|
+
return goto("finish", { greeting: `Hello, ${input.name}` });
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const finish = defineStep({
|
|
149
|
+
name: "finish",
|
|
150
|
+
next: [],
|
|
151
|
+
terminal: true,
|
|
152
|
+
async run(input: { greeting: string }, ctx) {
|
|
153
|
+
return terminate({ result: input.greeting });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export const agent = defineAgent({
|
|
158
|
+
name: "greet",
|
|
159
|
+
entry: "start",
|
|
160
|
+
steps: { start, finish },
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Cross-Step State with `ctx.shared`
|
|
165
|
+
|
|
166
|
+
`goto(target, payload)` passes data to the next step's `input`. For data multiple downstream
|
|
167
|
+
steps need, use `ctx.shared` — a typed key/value store that persists across the whole execution.
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
interface Shared extends Record<string, unknown> {
|
|
171
|
+
taskId: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// In a step:
|
|
175
|
+
ctx.shared.set("taskId", "abc-123");
|
|
176
|
+
|
|
177
|
+
// In a later step:
|
|
178
|
+
const taskId = ctx.shared.get("taskId"); // typed as string | undefined
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`ctx.shared` API: `get(key)`, `set(key, value)`, `has(key)`, `snapshot()`.
|
|
182
|
+
|
|
183
|
+
**A step's `run(input, ctx)` first argument is its inbound input** — the entry input at the
|
|
184
|
+
entry step, or the previous step's `goto(target, payload)` value at later steps. The entry
|
|
185
|
+
input reaches only the entry step's argument; to use it in later steps, write it into
|
|
186
|
+
`ctx.shared` from the entry step.
|
|
187
|
+
|
|
188
|
+
## `ctx` Reference
|
|
189
|
+
|
|
190
|
+
| Field | Type | Notes |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| `ctx.executionId` | `string` | Unique id for this execution |
|
|
193
|
+
| `ctx.agentName` | `string` | The agent's `name` |
|
|
194
|
+
| `ctx.input` | `unknown` | The execution's entry input — same value the entry step's `run` arg receives. Use `ctx.shared` to carry it forward; don't rely on `ctx.input` downstream. |
|
|
195
|
+
| `ctx.shared` | `TypedContextStore<TShared>` | Cross-step key/value store |
|
|
196
|
+
| `ctx.history` | `readonly StepExecutionRecord[]` | Previous steps' records |
|
|
197
|
+
| `ctx.attempts` | `number` | How many times this step has run (0-indexed) |
|
|
198
|
+
| `ctx.logger` | `StepLogger` | `info / warn / error / debug(msg, meta?)` |
|
|
199
|
+
| `ctx.sapiom` | `Sapiom` | The typed capability client — the `Sapiom` interface from `@sapiom/tools`, installed in your `node_modules` (see "Capabilities" below) |
|
|
200
|
+
| `ctx.organizationId` | `string \| null` | Tenant org |
|
|
201
|
+
| `ctx.tenantId` | `string \| null` | Tenant id |
|
|
202
|
+
|
|
203
|
+
## Capabilities from Steps
|
|
204
|
+
|
|
205
|
+
Steps call Sapiom's paid capabilities through `ctx.sapiom.*` — sandboxes, repositories,
|
|
206
|
+
coding models (`ctx.sapiom.models.coding`), file storage, content generation, search,
|
|
207
|
+
databases, email, domains, memory, and more as they land. **Do not memorize the catalog:
|
|
208
|
+
types are the source of truth.** The full surface is the `Sapiom` interface in `@sapiom/tools`
|
|
209
|
+
— installed in your project's `node_modules`, so its types match the exact version you're on.
|
|
210
|
+
`ctx.sapiom.` autocompletes what exists, `npm run typecheck` rejects what doesn't, and the full
|
|
211
|
+
catalog with pricing lives at [docs.sapiom.ai/capabilities](https://docs.sapiom.ai/capabilities).
|
|
212
|
+
|
|
213
|
+
## Failure Handling & Retries
|
|
214
|
+
|
|
215
|
+
There is no automatic per-step retry. Express it explicitly — this keeps the graph readable.
|
|
216
|
+
The common pattern is a bounded loop that escalates:
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const reconsider = defineStep({
|
|
220
|
+
name: "reconsider",
|
|
221
|
+
next: ["work", "escalate"],
|
|
222
|
+
async run(_input, ctx: AgentExecutionContext<{ attempt: number }>) {
|
|
223
|
+
const attempt = ctx.shared.get("attempt") ?? 0;
|
|
224
|
+
if (attempt >= 3) return goto("escalate", {});
|
|
225
|
+
ctx.shared.set("attempt", attempt + 1);
|
|
226
|
+
return goto("work", {});
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
For a step's own retries (transient errors):
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
async run(input, ctx) {
|
|
235
|
+
try {
|
|
236
|
+
const result = await ctx.sapiom.sandboxes.create({ name: "demo" });
|
|
237
|
+
return terminate({ result });
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// ctx.attempts is 0-indexed: `+ 1 < N` gives exactly N total attempts.
|
|
240
|
+
// (`ctx.attempts < N` would run N+1 times — a common off-by-one.)
|
|
241
|
+
if (ctx.attempts + 1 < 3) return retry({ delayMs: 1000 });
|
|
242
|
+
return fail("too many attempts"); // requires canFail: true
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`timeoutMs` on a step caps how long its `run` may take. There is no engine-level retry cap —
|
|
248
|
+
you own the bound.
|
|
249
|
+
|
|
250
|
+
## Pause & Resume (Long-Running Dispatched Steps)
|
|
251
|
+
|
|
252
|
+
A step's `run` completes in one synchronous dispatch. For long-running capabilities (a
|
|
253
|
+
dispatched coding-model run), **launch fire-and-forget and pause** — the engine suspends the
|
|
254
|
+
execution until the result signal fires, then resumes into a designated step whose `input`
|
|
255
|
+
IS the result payload.
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import {
|
|
259
|
+
defineAgent, defineStep, goto, pauseUntilSignal, terminate,
|
|
260
|
+
type AgentExecutionContext,
|
|
261
|
+
} from "@sapiom/agent";
|
|
262
|
+
import { CODING_RESULT_SIGNAL, type CodingResultPayload } from "@sapiom/tools";
|
|
263
|
+
|
|
264
|
+
interface Shared extends Record<string, unknown> {
|
|
265
|
+
repoSlug: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const launch = defineStep({
|
|
269
|
+
name: "launch",
|
|
270
|
+
next: ["collect"],
|
|
271
|
+
// Declare the signal and resume step so the engine knows what to wait for.
|
|
272
|
+
pause: { signal: CODING_RESULT_SIGNAL, resumeStep: "collect" },
|
|
273
|
+
async run(input: { task: string }, ctx: AgentExecutionContext<Shared>) {
|
|
274
|
+
const repo = await ctx.sapiom.repositories.create("my-repo");
|
|
275
|
+
ctx.shared.set("repoSlug", repo.slug); // stash before pausing
|
|
276
|
+
const run = await ctx.sapiom.models.coding.launch({ task: input.task, gitRepository: repo });
|
|
277
|
+
return pauseUntilSignal(run, { resumeStep: "collect" }); // pass the handle, not the signal name
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const collect = defineStep({
|
|
282
|
+
name: "collect",
|
|
283
|
+
next: [],
|
|
284
|
+
terminal: true,
|
|
285
|
+
// input IS the CodingResultPayload delivered by the resume signal.
|
|
286
|
+
async run(result: CodingResultPayload, ctx: AgentExecutionContext<Shared>) {
|
|
287
|
+
if (result.status !== "completed") {
|
|
288
|
+
return terminate({ status: result.status, error: result.error });
|
|
289
|
+
}
|
|
290
|
+
// Re-attach the sandbox — the payload crossed a wire boundary, so there are no live handles.
|
|
291
|
+
if (result.executionEnvironment?.type === "blaxel_sandbox") {
|
|
292
|
+
const sandbox = ctx.sapiom.sandboxes.attach(result.executionEnvironment.id);
|
|
293
|
+
// … push from sandbox, read files, etc.
|
|
294
|
+
}
|
|
295
|
+
return terminate({ status: result.status, summary: result.summary });
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
export const agent = defineAgent<{ task: string }, Shared>({
|
|
300
|
+
name: "code-and-collect",
|
|
301
|
+
entry: "launch",
|
|
302
|
+
steps: { launch, collect },
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Key rules:
|
|
307
|
+
|
|
308
|
+
- `pause: { signal, resumeStep }` is **required** on the step that returns `pauseUntilSignal`.
|
|
309
|
+
Passing the handle to `pauseUntilSignal(handle, ...)` wires the signal automatically.
|
|
310
|
+
- The **resumed step's `input` is the run's result payload** (`CodingResultPayload`).
|
|
311
|
+
Annotate it explicitly — don't hand-roll the shape.
|
|
312
|
+
- The payload crossed a process boundary: **no live handles**. Re-attach a sandbox from
|
|
313
|
+
`result.executionEnvironment.id` if needed; stash everything else in `ctx.shared` before pausing.
|
|
314
|
+
- For a **manual human-gate** (no capability handle), use the object form and fire the signal
|
|
315
|
+
from your approval flow:
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
return pauseUntilSignal({
|
|
319
|
+
signal: "my.approval",
|
|
320
|
+
resumeStep: "finalize",
|
|
321
|
+
correlationId: ctx.executionId, // makes the awaited signal unique to this execution
|
|
322
|
+
});
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Under `run_local`, a dispatch pause auto-resumes with the stub result; a manual gate
|
|
326
|
+
auto-resumes with `{}` unless stubbed — type the resumed step's input with optional fields
|
|
327
|
+
accordingly.
|
|
328
|
+
|
|
329
|
+
## Determinism
|
|
330
|
+
|
|
331
|
+
A step body runs **once** on the happy path. It re-runs only on retry (after a throw or
|
|
332
|
+
`retry()`). Do not rely on a value being recomputed identically across a pause/resume or a
|
|
333
|
+
retry. Capture non-deterministic values (timestamps, random ids) once and carry them forward
|
|
334
|
+
via `goto` input or `ctx.shared`.
|
|
335
|
+
|
|
336
|
+
## Testing with `run_local` and Stubs
|
|
337
|
+
|
|
338
|
+
`run_local` works with **no stubs** — capabilities return sensible defaults. Add
|
|
339
|
+
`.sapiom-dev/stubs.json` overrides only when a step branches on a specific result:
|
|
340
|
+
|
|
341
|
+
```jsonc
|
|
342
|
+
{
|
|
343
|
+
"version": 1,
|
|
344
|
+
"steps": {
|
|
345
|
+
// Stub the coding run under the LAUNCHING step (here `launch`), not the resume step.
|
|
346
|
+
"launch": {
|
|
347
|
+
"models.coding.run": { "status": "completed", "summary": "done", "result": null, "error": null, "executionEnvironment": null }
|
|
348
|
+
},
|
|
349
|
+
"check": {
|
|
350
|
+
"repositories.list": [{ "slug": "my-repo", "cloneUrl": "https://..." }]
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Stub naming rules:
|
|
357
|
+
|
|
358
|
+
- Namespace calls use the **plural/namespace path**: `repositories.list`, `models.coding.run`.
|
|
359
|
+
- Handle method calls use the **singular**: `repository.pushFromSandbox`, `sandbox.exec`.
|
|
360
|
+
- To stub a coding run's resume payload, override `models.coding.run` (or
|
|
361
|
+
`models.coding.launch`) in the **launching step** — that value is both the inline result
|
|
362
|
+
and the payload the paused step resumes with.
|
|
363
|
+
- `run_local` reports `unusedStubs` (key matched nothing — usually a typo or plural/singular
|
|
364
|
+
slip) and `stubWarnings` (key matched but wrong shape). A green run with either non-empty
|
|
365
|
+
means the stub silently didn't apply.
|
|
366
|
+
- **Local retry cap:** the `run_local` tool defaults to `maxAttemptsPerStep: 3`. If a step's
|
|
367
|
+
own retry bound allows ≥3 retries, pass a higher `maxAttemptsPerStep` so the local harness
|
|
368
|
+
doesn't stop the loop before your `fail()` fires. This cap is local-test only — production
|
|
369
|
+
has no engine-level retry cap.
|
|
370
|
+
|
|
371
|
+
Write each step the way it should run in production — never weaken logic to shape a local run.
|
|
372
|
+
|
|
373
|
+
## Tips
|
|
374
|
+
|
|
375
|
+
- **Types are the source of truth.** What's on `ctx.sapiom` is defined by `@sapiom/tools`.
|
|
376
|
+
Use autocomplete and `npm run typecheck` rather than guessing — a wrong capability name is
|
|
377
|
+
a type error, not a runtime surprise.
|
|
378
|
+
- **`check` before deploy.** It validates the step graph (names, `next` references,
|
|
379
|
+
`terminal` consistency) — a misconfigured graph is caught here, not at runtime.
|
|
380
|
+
- **One `defineAgent` export per file.** The scaffold wraps a single `index.ts`.
|
|
381
|
+
- **`ctx.shared` for fanout.** When three steps all need the entry input, write it into
|
|
382
|
+
`ctx.shared` in the entry step — don't thread it through every `goto` payload.
|
|
383
|
+
- **One-off capability call, no automation to keep?** That's not an agent — use Sapiom's
|
|
384
|
+
[remote MCP](https://docs.sapiom.ai/integration/mcp-servers/remote) (`https://api.sapiom.ai/v1/mcp`,
|
|
385
|
+
direct `sapiom_*` tools, `tool_discover` to find the right one) or the typed SDK client
|
|
386
|
+
([`@sapiom/tools`](https://www.npmjs.com/package/@sapiom/tools)) instead of scaffolding.
|
|
387
|
+
|
|
388
|
+
## Troubleshooting
|
|
389
|
+
|
|
390
|
+
| Symptom | Cause | Fix |
|
|
391
|
+
|---|---|---|
|
|
392
|
+
| `Cannot find module '@sapiom/agent'` | Deps not installed | `npm install` inside the scaffolded dir |
|
|
393
|
+
| Type error: `fail(...)` not assignable | Step missing `canFail: true` | Add `canFail: true` to `defineStep` |
|
|
394
|
+
| Type error: `terminate(...)` not assignable | Step missing `terminal: true` | Add `terminal: true` to `defineStep` |
|
|
395
|
+
| `goto` target rejected by types | Target not in `next[]` | Add the target name to `next` |
|
|
396
|
+
| `check` fails: step missing from graph | `steps` object key doesn't match `name` field | Match the key in `steps: { start }` to `defineStep({ name: "start" })` |
|
|
397
|
+
| `run_local` reports `unusedStubs` | Stub path typo or namespace/handle mix-up | Namespace path for calls (`repositories.list`), singular for handles (`repository.pushFromSandbox`) |
|
|
398
|
+
| Paused step resumes with empty input | Manual gate; `run_local` auto-resumes with `{}` | Type the resumed step's input with optional fields |
|
|
399
|
+
| `sapiom_authenticate` → credential not found at deploy | Authenticated in a different shell | Re-run `sapiom_authenticate`; credential is per-machine in `~/.sapiom/credentials.json` |
|
|
400
|
+
|
|
401
|
+
## References
|
|
402
|
+
|
|
403
|
+
| Resource | What it covers |
|
|
404
|
+
|---|---|
|
|
405
|
+
| [Authoring guide](https://docs.sapiom.ai/agents/authoring) | Full step model, failure patterns, pause/resume, determinism |
|
|
406
|
+
| [Quickstart](https://docs.sapiom.ai/agents/quick-start) | Scaffold → write → test → deploy walkthrough |
|
|
407
|
+
| [Capabilities](https://docs.sapiom.ai/capabilities) | The full `ctx.sapiom.*` catalog with pricing |
|
|
408
|
+
| `AGENTS.md` in your scaffold | The quick in-project reference |
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sapiom-sandbox-preview
|
|
3
|
+
description: Deploy a live preview of a web app from the current project to a
|
|
4
|
+
Sapiom sandbox. Use when the user wants to preview, host, or deploy a web
|
|
5
|
+
app, dev server, API, or static site to a live URL ("preview this app",
|
|
6
|
+
"give me a live link", "host this server"), or mentions sapiom.json sandbox
|
|
7
|
+
resources. Do NOT use for deploying Sapiom agents (that's
|
|
8
|
+
sapiom_dev_agents_deploy) or for one-off capability calls.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Sandbox Previews
|
|
12
|
+
|
|
13
|
+
Deploy the web app in the current project directory to a Sapiom **sandbox** and get a
|
|
14
|
+
**live public URL** — provisioned, uploaded, built, and started in one tool call. Driven
|
|
15
|
+
by the sapiom-dev MCP (`npx -y @sapiom/mcp`; see the [Get Started guide](https://docs.sapiom.ai/)
|
|
16
|
+
if it isn't connected).
|
|
17
|
+
|
|
18
|
+
**This is not agent deployment.** Sapiom *agents* deploy with `sapiom_dev_agents_deploy`;
|
|
19
|
+
sandbox previews host an ordinary app (Node server, static site, API) from your working
|
|
20
|
+
directory.
|
|
21
|
+
|
|
22
|
+
## Prerequisite
|
|
23
|
+
|
|
24
|
+
Run `sapiom_authenticate` once (browser login; caches a key in `~/.sapiom/credentials.json`).
|
|
25
|
+
`sapiom_dev_sandbox_preview` returns a structured not-authenticated error otherwise;
|
|
26
|
+
`configure` and `check` only touch local files and work signed-out. Check with
|
|
27
|
+
`sapiom_status`.
|
|
28
|
+
|
|
29
|
+
## The lifecycle
|
|
30
|
+
|
|
31
|
+
1. **`sapiom_dev_sandbox_configure`** — creates or updates a preview resource in the
|
|
32
|
+
project's `sapiom.json`. Fill the typed arguments instead of hand-writing JSON — the
|
|
33
|
+
config is validated and written under `resources.<name>` (`type: "sandbox"`). Returns
|
|
34
|
+
the stored config.
|
|
35
|
+
2. **`sapiom_dev_sandbox_check`** *(optional)* — statically validates the resources without
|
|
36
|
+
deploying. Returns `{ ok, sandboxes, issues }`; fix any `issues` before previewing.
|
|
37
|
+
3. **`sapiom_dev_sandbox_preview`** — reads `sapiom.json`, provisions the sandbox if
|
|
38
|
+
needed, uploads the local code, builds, starts, and exposes a public URL. Returns
|
|
39
|
+
`{ name, url, status, logs }`. Pass `name` only when the project defines more than one
|
|
40
|
+
resource.
|
|
41
|
+
|
|
42
|
+
**A `failed` status is not an error** — it carries the build/start logs so you can fix the
|
|
43
|
+
app or the config and run `sapiom_dev_sandbox_preview` again. `unverified` means the app
|
|
44
|
+
started but didn't answer 2xx yet.
|
|
45
|
+
|
|
46
|
+
## The `sapiom.json` resource
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"version": 1,
|
|
51
|
+
"resources": {
|
|
52
|
+
"web": {
|
|
53
|
+
"type": "sandbox",
|
|
54
|
+
"source": { "kind": "upload" },
|
|
55
|
+
"start": "node server.js",
|
|
56
|
+
"port": 3000,
|
|
57
|
+
"ttl": "1h"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Field | Required | Notes |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `source` | yes | `{ "kind": "upload", "path"? }` (upload the local dir) or `{ "kind": "git", "slug", "path"? }` (server checks out a Sapiom repo) |
|
|
66
|
+
| `start` | yes | The server command (e.g. `node server.js`) |
|
|
67
|
+
| `port` | yes | 1–65535 — the port your app listens on |
|
|
68
|
+
| `build` | no | Build command run before start (e.g. `npm run build`) |
|
|
69
|
+
| `tier` | no | Sandbox size: `xs` \| `s` \| `m` \| `l` \| `xl` |
|
|
70
|
+
| `ttl` | no | Sandbox lifetime, e.g. `"1h"`, `"24h"`, `"7d"` |
|
|
71
|
+
| `env` | no | Environment variables (string map) |
|
|
72
|
+
|
|
73
|
+
Uploads skip `node_modules`, `.git`, and dotfiles — dependencies install in the sandbox at
|
|
74
|
+
build time; never upload them.
|
|
75
|
+
|
|
76
|
+
## CLI alternative
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
sapiom sandbox preview [name] # alias: sapiom sbx preview; add --json for machine output
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Defaults to the single resource when the project defines exactly one.
|
|
83
|
+
|
|
84
|
+
## From inside a Sapiom agent step
|
|
85
|
+
|
|
86
|
+
Steps can deploy previews through the typed client. Get a `Sandbox` instance first —
|
|
87
|
+
`await ctx.sapiom.sandboxes.create({...})` or `.attach(name)` — then call
|
|
88
|
+
`sandbox.uploadDir(localDir)` to stage the code and
|
|
89
|
+
`sandbox.deployPreview({ start, port, build?, env? })` →
|
|
90
|
+
`{ url, status: "deployed" | "unverified" | "failed", logs }` (failures return `status:
|
|
91
|
+
"failed"` with logs, not a throw). `sandbox.createPublicUrl({ port })` is the low-level
|
|
92
|
+
primitive — the port must have been declared when the sandbox was created.
|
|
93
|
+
|
|
94
|
+
## Reference
|
|
95
|
+
|
|
96
|
+
Full details: [Compute — Sandbox previews](https://docs.sapiom.ai/capabilities/compute#sandbox-previews).
|
|
@@ -28,7 +28,7 @@ When you've made a coherent change and want to validate it — the same point yo
|
|
|
28
28
|
{ "version": 1, "steps": { "<stepName>": { "<capability.path>": <response> } } }
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
- Capability paths are namespace methods (`repositories.list`, `repositories.create`, `
|
|
31
|
+
- Capability paths are namespace methods (`repositories.list`, `repositories.create`, `models.coding.run`) or handle methods, which use the **singular** handle type (`repository.pushFromSandbox`, `sandbox.exec`) — not the plural namespace.
|
|
32
32
|
- `<response>` is returned **verbatim** — it is the value that call would return, so match its real shape. `repositories.list` takes the array `list()` returns: `[{ "slug": "...", "cloneUrl": "..." }]` (each element a repository — *not* `[[ … ]]`). `repositories.create`/`get`/`attach` take a single `{ "slug", "cloneUrl" }`.
|
|
33
33
|
- `run_local` reports **`unusedStubs`** (a key that matched no call — usually a typo or the plural/singular mistake) and **`stubWarnings`** (a key matched but the value was the wrong shape). A green run with either non-empty means a stub silently didn't take effect — check them.
|
|
34
34
|
|
|
@@ -43,7 +43,7 @@ return pauseUntilSignal(run, { resumeStep: "finalize" });
|
|
|
43
43
|
|
|
44
44
|
- **The resumed step's `input` IS the run's result signal payload.** Annotate it with `CodingResultPayload` (from `@sapiom/tools`) — you don't have to hand-roll the shape.
|
|
45
45
|
- That payload crossed a wire boundary, so it carries **no live handles** — to act on the run's sandbox, re-attach one from **`executionEnvironment`** with `ctx.sapiom.sandboxes.attach(result.executionEnvironment.id)` (`executionEnvironment` is `null` when the run provisioned none, e.g. a launch failure). Anything else the resumed step needs, stash in `ctx.shared` before pausing.
|
|
46
|
-
- **To stub the resume payload** (e.g. to exercise the failure branch), override `
|
|
46
|
+
- **To stub the resume payload** (e.g. to exercise the failure branch), override `models.coding.run` *in the launching step* — that one value is both the `run()` result and the payload the paused step resumes with. `models.coding.launch` is accepted there too.
|
|
47
47
|
|
|
48
48
|
## Determinism
|
|
49
49
|
|
|
@@ -9,7 +9,7 @@ resumes once the run finishes — authored as code against
|
|
|
9
9
|
prepare → kickoff ──pause(models.coding.result)──▶ finalize
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
- **kickoff** calls `
|
|
12
|
+
- **kickoff** calls `models.coding.launch(...)` (which returns a handle, *not* a
|
|
13
13
|
result) and returns `pauseUntilSignal(handle, { resumeStep: "finalize" })`. The
|
|
14
14
|
workflow suspends — a long run holds no worker.
|
|
15
15
|
- **finalize** is resumed by the engine when the run reaches a terminal state.
|
|
@@ -22,7 +22,7 @@ branch locally, stub a failed result under the *launching* step in
|
|
|
22
22
|
`.sapiom-dev/stubs.json` — that value is also the resume payload:
|
|
23
23
|
|
|
24
24
|
```jsonc
|
|
25
|
-
{ "version": 1, "steps": { "kickoff": { "
|
|
25
|
+
{ "version": 1, "steps": { "kickoff": { "models.coding.launch": { "status": "failed", "result": { "success": false }, "error": { "stage": "run", "message": "…" } } } } }
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
## Getting started
|
|
@@ -28,7 +28,7 @@ When you've made a coherent change and want to validate it — the same point yo
|
|
|
28
28
|
{ "version": 1, "steps": { "<stepName>": { "<capability.path>": <response> } } }
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
- Capability paths are namespace methods (`repositories.list`, `repositories.create`, `
|
|
31
|
+
- Capability paths are namespace methods (`repositories.list`, `repositories.create`, `models.coding.run`) or handle methods, which use the **singular** handle type (`repository.pushFromSandbox`, `sandbox.exec`) — not the plural namespace.
|
|
32
32
|
- `<response>` is returned **verbatim** — it is the value that call would return, so match its real shape. `repositories.list` takes the array `list()` returns: `[{ "slug": "...", "cloneUrl": "..." }]` (each element a repository — *not* `[[ … ]]`). `repositories.create`/`get`/`attach` take a single `{ "slug", "cloneUrl" }`.
|
|
33
33
|
- `run_local` reports **`unusedStubs`** (a key that matched no call — usually a typo or the plural/singular mistake) and **`stubWarnings`** (a key matched but the value was the wrong shape). A green run with either non-empty means a stub silently didn't take effect — check them.
|
|
34
34
|
|
|
@@ -43,7 +43,7 @@ return pauseUntilSignal(run, { resumeStep: "finalize" });
|
|
|
43
43
|
|
|
44
44
|
- **The resumed step's `input` IS the run's result signal payload.** Annotate it with `CodingResultPayload` (from `@sapiom/tools`) — you don't have to hand-roll the shape.
|
|
45
45
|
- That payload crossed a wire boundary, so it carries **no live handles** — to act on the run's sandbox, re-attach one from **`executionEnvironment`** with `ctx.sapiom.sandboxes.attach(result.executionEnvironment.id)` (`executionEnvironment` is `null` when the run provisioned none, e.g. a launch failure). Anything else the resumed step needs, stash in `ctx.shared` before pausing.
|
|
46
|
-
- **To stub the resume payload** (e.g. to exercise the failure branch), override `
|
|
46
|
+
- **To stub the resume payload** (e.g. to exercise the failure branch), override `models.coding.run` *in the launching step* — that one value is both the `run()` result and the payload the paused step resumes with. `models.coding.launch` is accepted there too.
|
|
47
47
|
|
|
48
48
|
## Determinism
|
|
49
49
|
|