API reference
Authoring factories, builders, resource operations, context properties, and triggers.
Use this page to look up authoring options. Foundation explains the relationships between definitions, instances, and contexts. Agent sessions covers advanced session options.
Durability defines the execution guarantees; Foundry primitives maps these operations to the libraries beneath them. Package APIs can expose capabilities beyond the authoring interface described here.
Code blocks on this page illustrate API forms and may refer to definitions from earlier blocks. For complete runnable modules, use the quickstart or composition examples.
Authoring factories
import {
agent,
artifact,
monitor,
sandbox,
schedule,
skills,
step,
workflow,
workspace,
} from "@foundry.sh/marbles";
| Factory | Declaration | Purpose |
|---|---|---|
agent |
agent({ name?, prompt, model?, provider?, settings?, skills? }) |
Who does model work, with defaults. |
workspace |
workspace({ path }) |
Another directory to work on. The current workspace is implicit. |
sandbox |
sandbox({ image, mount?, target?, … }) or sandbox({ files, image?, … }) |
An isolated place to run commands. |
artifact |
artifact({ name, type }) |
A named output; each write adds a version. |
skills |
skills.load().add(glob).pick(...names) |
A skill set an agent takes. |
step |
step(name?).describe(text).input(s).output(s).config(c).do(fn) |
One piece of work. |
workflow |
workflow(name?, tree), workflow(name?, () => tree), workflow(name?).input(s).do(fn) |
A named tree of steps. |
schedule |
schedule(target).at(slot) or .every(interval) |
Runs a step or workflow on a clock. |
monitor |
monitor(source).request?(init).every?(interval).do(fn) |
Runs when files or an HTTP response change. |
Steps, workflows, and triggers register as modules load. Exports are optional
and useful for sharing definitions. Import singular factories to declare resources. A step uses the plural
context properties agents, workspaces, sandboxes, and artifacts to
operate on them.
Definitions
Resource declarations collect reusable settings. Some context methods also
accept those settings inline. Returned instances expose operations such as
generate, files, and write; see Using a definition.
const reviewer = agent({
prompt: "Review without editing.",
provider: "codex",
});
const drafter = agent({ prompt: "Draft the page.", provider: "claude-code" });
const site = workspace({ path: "../marketing-site" }); // relative to the workspace root
const box = sandbox({ image: "docker.io/oven/bun:1-slim", mount: "." });
const scratch = sandbox({ files: { "main.py": "print(1)" } }); // seeded under /workspace
const weeklyReport = artifact({ name: "Weekly report", type: "text/markdown" });
const reviewing = skills.load().add("./skills/review/*").pick("caveman");
Agent settings
prompt is required. State the agent’s task and limits, such as “Review
without editing.”
provider is "codex", "claude-code", or "pi"; omitted means the first
installed CLI, Claude Code before Codex. model is optional: omit it for
the provider’s default, or set an id the provider serves. It does not pick
the provider; an id the provider does not know is passed through with a
warning, and --dry-run rejects it. skills takes a skill set.
Tool grants and agent-created triggers are stored under the agent’s ID: name
when given, else the top-level const it is assigned to, else agent-
and a digest of its prompt, provider, and model. Two different agents with
one id fail at load with already declared … with a different prompt or route; the same agent declared twice is fine.
Workspace settings
A workspace declaration selects a directory. It creates no dashboard entry or separate runtime state. A sandbox may mount the directory.
Sandbox settings
The image form mounts a workspace read/write at /workspace. mount: "."
uses the step’s working directory, including its current Git worktree. A
sandbox can also mount a declared workspace, such as mount: site. Other
host paths fail with escapes the trusted host roots.
The files form mounts no host workspace. It copies its seed files under
/workspace after boot. Host authentication and state directories are never
mounted automatically. Workspace mounts require executable: true to run
files; network access defaults to disabled.
target selects the mount location; workdir selects the working directory.
Other container options are network, ports, labels, publicEnvironment,
detached, and resources: { cpus?, memoryBytes?, pids? }. Running a sandbox
requires the optional microsandbox peer. Dry execution echoes commands
without starting it.
Artifact identity
A declared artifact keeps its identity across runs; each
report.artifact or artifacts.write adds a version to it.
Skills
skills.load() is the global set under ~/.foundry/skills
plus the workspace’s skills/ folder; skills.none() starts empty.
.add(glob) appends every folder matching the glob that holds a
SKILL.md, and .add(skill) appends a skill defined in code with
defineSkill({ manifest, files? }), exported from @foundry.sh/marbles.
.pick(...names) narrows. Each call returns a new set. A skill’s files
are read as bytes: text stays text, and an agent loading a binary file
is told its size rather than shown mangled bytes.
Using a definition
Inside a step, call an agent instance for a single turn:
const reply = await reviewer.generate("Review this change.");
const stream = reviewer.stream("Review the next change.");
Use .run(input) to execute a step directly. This excerpt assumes z is
imported from zod:
const countLines = step("count-lines")
.input(z.object({ text: z.string() }))
.do(({ input }) => input.text.split("\n").length);
const lines = await countLines.run({ text: "First line\nSecond line" });
| Instance | Operations | Context equivalent |
|---|---|---|
agent |
generate(input, turn?), stream(input, turn?) |
agents.session(def), one turn |
step, workflow |
run(input?) |
Direct execution within the current process; no separate dashboard run. |
workspace |
root, files(), git |
workspaces.load(def) |
sandbox |
exec, close, and the container’s commands, filesystem, processes, shell, ports, services, volumes; started by the first call |
sandboxes.start(def) |
artifact |
write(files, options?), latest() |
artifacts.write(def, files), artifacts.latest(def) |
- Inside a step, instance methods use its working directory, output, and cancellation. Replay depends on the operation: managed artifact writes and completed one-shot agent replies can be reused; file reads and commands can execute again. Keep managed call order stable. See the replay table.
- Anywhere else (module top level, a plain
bunscript) it runs on the enginemarblesbuilt for the module, or, with none, on a default one: the installed CLI harnesses, the current directory as the workspace, and the shared artifact store under~/.foundry/artifacts. Calldispose()from@foundry.sh/marbleswhen a script is done with it; a Codex turn keeps a child process open until then. - An agent’s call is one turn on a fresh session. For a conversation,
open a session with
agents.session(designer). The data fields are plain properties and the methods are not, so{ ...designer }is a plain definition thatagents.sessiontakes. - A sandbox starts at its first call. In a step it is that step’s
sandbox, closed with the run and reopened on replay; elsewhere it runs
until
close()ordispose(). Its filesystem isbox.filesystem, since a files sandbox’sfilesis the seed it was declared with.agents.session(def, { sandbox: box })takes the declared sandbox and starts it the same way. runruns in this process, not through the engine’s queue, so a body can await it; a nameless definition runs too. The nested run is not shown on the dashboard and cannot be paused on its own. A step that asks a person with nobody to answer rejects withasked a person; run it with marbles to answer.
The builder
A builder ends with either .do(body) or .exec(command). The forms below
show the available options, not a single executable chain.
step(name?) // a builder
.describe(text) // optional: shown in the catalog and the launch form
.input(schema) // optional: any Standard Schema library (zod, valibot, arktype)
.output(schema) // optional
.config({ retry?, timeout? }) // optional: how the step runs
.do(fn) // returns the definition
// or, for a step that is one command:
.exec("bun add $package", { check?, cwd?, environment?, sandbox? })
- A string first argument makes the step launchable by that name. A repeated
name fails at load. Without a name the step takes the name of the top-level
constit is assigned to when the project’stypescriptresolves from the workspace root; otherwise it is internal. See names and the catalog. - Schemas come before
.do()so the body is typed from them..input()gives the TypeScript type, validation at launch, a contract the catalog can read, and (with Standard JSON Schema support) structured fields in the dashboard launch form. Other input schemas use a JSON field. No input schema means no launch input..output()validates the body’s return. - A parent declares its children’s results in
.input()like any other input. Each child’s output is checked against the parent’s key at lock. doreceives one object; destructure it. It may return a value or a promise. Results must be plain JSON: a step whose result holds aDate, aMap, or a class instance fails with where it stops being JSON (files[0].updatedAt is a Date). Return an ISO string for a date..config({ retry, timeout }).timeoutbounds each attempt of the body ("30s","10m", or milliseconds): when it passes the body’ssignalaborts, with everything Marbles opened for it, and the attempt fails.retryis the Workflows policy,{ maxAttempts, backoff?, maxElapsed?, shouldRetry? }, withbackoffone offixed,exponential,linear, orjittered. A retried attempt runs the body again from the top, fresh: agent turns, sandboxes, artifact writes, andruncalls it made are not replayed. Children that finished are not run again.maxAttemptsincludes the first attempt. Neither retries nor a timeout are enabled by default, and a parent’s policy does not become its children’s policy. Withoutretry, a throw fails the step..exec(template, options?)ends a step whose work is one command. The template is split into arguments when the module loads, honouring quotes, and never read by a shell.$namefills one argument from the input, never split again, so{"package":"two words"}is one package argument; an array fills one argument per element. The program, the first word, must be written out, and each placeholder must be an input key, or the module fails to load..exec(({ input }) => argv)computes the list instead. The step’s output is{ exitCode, stdout, stderr }; a non-zero exit fails it unless{ check: false }. It runs like a body’sexec.bail(error), exported from@foundry.sh/marbles, is a failure as a value: return it fromdoto fail the step without a retry. It is not checked against the output schema.
workflow uses the same builder plus two short forms:
workflow(
"weekly",
publish.parallel({ findings: review({}), exitCode: test({}) })
);
workflow("nightly", () => publish({ findings: review({}, { target: "src" }) }));
workflow("ship")
.input(z.object({ task: z.string() }))
.do(({ input: { task } }) =>
approve({ review: review({ branch: implement({}, { task }) }) })
);
A workflow’s .do is setup: it receives { input, log, run: { id } } only
and returns the tree. It is not durable; see
composition and durability.
Locking and trees
Calling a definition creates a node in the workflow tree. This operation is called locking. It selects children and literal input without executing work.
review({}, { target: "." }); // leaf: no children, literal input
publish({ findings: review({}) }, { exitCode: 0 }); // one child, one literal
publish.series({ a: x({}), b: y({}) }); // children in key order (default)
publish.parallel({ a: x({}), b: y({}) }); // children together
- Children first. An object of locked nodes keyed by name. A leaf passes
{}. The keys name the children in the dashboard, in logs, on resume, and in the parent’s input. - Input second, optional. Literal values for the step’s input. The literal takes the schema’s input type, so defaulted fields are optional.
- The children’s results and the literal input merge into the body’s
input. A key in both is an error at load, as is a child key the parent’s schema does not declare. - The parent runs last, after its children. Siblings are independent; data flows only from a child to its parent. A step that needs another’s result takes it as a child.
- One definition can be locked many times; each lock is its own node.
.parallelis the only combinator besides the default series. There is no race, branch, or loop yet. A workflow is a definition too:weekly({})locks it. A workflow lock is only ever the root of a run, launched, scheduled, or returned by a monitor. As a child in another tree it throwsa workflow can only be the root of a run.
In a parallel group the first failure fails the run; the siblings are aborted and awaited before the failure is reported. A sibling that parks on a question parks the parent with it while the others finish.
Context
Workflow setup receives only { input, log, run: { id } }. Step bodies
receive the full context below. Monitor handlers extend it with change
data. See Contexts for when each is used.
| Property | Use |
|---|---|
input |
Validated literal input and child results. |
exec |
Run a command. |
agents |
Open an agent session. |
workspaces |
Read files and create Git checkouts. |
sandboxes |
Start and use an isolated environment. |
artifacts |
Write and read versioned outputs. |
ask |
Wait for a person. |
report |
Post results to the Feed. |
log, stream |
Write output. |
signal |
Observe cancellation or pause of this step or its run. |
run |
Identify or abort the run. |
Commands
const result = await exec(["bun", "test"], { check: false });
exec(argv, { check?, cwd?, environment?, sandbox? }) returns
{ exitCode, stdout, stderr }. It runs an argument list in the step’s working
directory, streams output, and stops with the step. A nonzero exit fails the
step unless check: false. Dry execution prints the command and substitutes
an empty successful result.
Agent sessions
const session = await agents.session(reviewer);
const reply = await session.generate("Review the change.");
Use { cwd: checkout.root } to select another host directory, or
{ session: previousRef } to continue a conversation.
Agent sessions lists host and sandbox options.
A session’s turns write to the step’s stream and stop with the
step. generate returns the message with text (every text part of the
final turn), output, usage, and result (the AI SDK’s result, absent
for Claude Code and Codex); stream returns the harness’s stream, with
output and partialOutputStream beside text and message. ref is a
small serializable handle (id, and the model and provider it opened
with) for continuing the session later. Network-model sessions can use compaction: true to summarize older
history. Native CLI sessions manage compaction themselves and reject
explicit host compaction settings.
Turn options
The second argument to generate and stream takes
output (an AI SDK Output), the AI SDK’s model settings (temperature,
maxOutputTokens, topP, topK, seed, stopSequences, stopWhen,
toolChoice, activeTools, reasoning, providerOptions, …), signal,
and handlers such as onText. They override the agent’s settings. A
turn whose output gets no matching value rejects with
NoObjectGeneratedError after the reply is committed. See
structured output.
Workspace operations
const project = await workspaces.load({ path: "../website" });
await project.git.withWorktree(
{ base: "main", branch: "task/update" },
async (checkout) => {
// Use checkout.root for raw filesystem calls inside this callback.
}
);
workspaces.current selects the current project. load(definition) or
load({ path }) selects another one. A handle exposes root, files,
entries, read, save, createFile, refresh, summary, and git.
A handle is the Workspaces workspace, loaded
at its first call: files() returns file entries (id, path,
extension, kind, size, and modification time), entries() adds
directories, read(file.id) returns the content, and save and
createFile write through the workspace’s confinement checks. A handle
from load also has its registry id and source. Renaming or removing
a workspace belongs to the host. current.git throws when the root is not
a Git repository.
withWorktree({ base, branch?, home? }, body) creates a checkout
under the workspace’s state, runs body with the working directory
narrowed to it, and removes it on return; a branch outlives the worktree.
worktree(options) creates one you must remove yourself, under the same home.
Naming a branch that exists fails before creating the checkout; check first
with hasBranch(name), which reads the repository even under
--dry-run.
Sandbox operations
const environment = await sandboxes.start(box);
const result = await environment.exec(["bun", "test"]);
start(definitionOrSpec) returns a handle; open(id) reopens one. The handle
provides id, exec, commands, files, processes, shell, ports,
services, volumes, and close(). Its exec returns an exit code even on
failure. Use the context’s exec(argv, { sandbox: box }) for automatic checking.
A handle is the Sandbox container. In a step
every command and process carries the step’s signal, and exec writes
$ <command> and its output to the step’s stream. exec takes an argv
array, run directly, or a string, run by the guest’s shell, and the
package’s options (cwd, environment, user). Listing, removing, and
restarting containers are the host’s.
Questions and approvals
const decision = await ask.approval({
title: "Publish this revision?",
body: findings,
});
const region = await ask.question({
title: "Which region?",
choices: ["us", "eu"],
});
approval({ title, body?, key? }) returns { approved, note? }.
question({ title, body?, choices?, key? }) returns a string.
approval is a hard block: the run shows as blocked until someone
decides. question is input: the step waits, siblings keep going. Both
need a process that can show them; the dashboard parks the run, while
roll and headless run cancel it at the ask. choices gives a pick list
of at most nine; without it the answer is free text. Both are identified
by position in the body; key is optional, for updating one entry across
steps.
Artifact operations
| Method | Result |
|---|---|
write(definition, files, { replace?, metadata? }) |
A new version of a declared artifact. |
create({ name, type, entries, metadata?, freeze? }) |
A one-off artifact and its first version. |
get(definitionOrId) |
The artifact record. |
listContents(definitionOrId) |
Its versions. |
getContent(versionId) |
One version. |
readFile(versionId, path), readRoot(versionId) |
Stored file content or its tree. |
A version is the Artifacts content record as
JSON: id, artifactId, digest, state, tag, metadata, tree,
and ISO timestamps. A version written in a step records the run and its
session in metadata (runId, sessionId). write replaces the files
unless { replace: false }; a file is text, bytes, or the package’s file
input ({ bytes, mime }, a stored blobId). Reads take the artifact’s
declaration or its package id, and a version’s id.
Read across runs
artifacts.latest(def) (or def.latest()) is
the artifact’s current version with text(path) and bytes(path), or
undefined before the first write. A declared artifact belongs to the
workspace, so this is what the last run here kept. Under --dry-run it
reads what real runs kept while its own writes stay in memory. See the
persistent reviewer.
Reports
report.milestone({ title: "Tests passed" });
report.result({ title: "Review", body: findings });
const version = await report.artifact(
weeklyReport,
{ "report.md": findings },
{ body: findings }
);
milestone and result accept { title, body?, media?, key? }.
artifact(definition, files, entry?) accepts optional entry settings.
Entries are fire-and-forget and land on the run and the feed.
media paths resolve from the workspace root and are copied into the
entry; refer to them in the body as media/<file name>. report.artifact
writes a version, posts a result entry titled after the artifact unless
entry.title says otherwise, and returns the version. The entry references
that exact version. Supply entry.body for readable Feed content; artifact
files are not automatically displayed by the Feed reader.
An artifact write by itself does not post to the Feed. Feed entries persist too, using the artifact store internally. See Artifacts and the Feed for choosing between stored outputs, messages, and returned results.
Output and cancellation
log(...values) logs at info level; log.debug, .info, .warn, and
.error select another level. stream.write(value) and stream.pipe(source)
write to the step’s live output. Pass signal to operations you own:
await fetch(url, { signal });
Everything Marbles owns is already cancelled with the
step and already writes to the step’s stream. signal and stream are for
the author’s own work: a raw fetch, a child process, a line of output.
Run information
run exposes { id, path, session, signal, stream, abort(reason) }. Use
run.id to associate an artifact or log entry with this execution.
run.abort(reason) fails the whole run. run.session is the
run’s own session; agents opened in the run are children of it.
run.stream is the whole run’s output.
Monitor observations
A monitor’s handler extends the step context with files or response,
plus change; its input is empty. Narrow change.kind before accessing
source-specific data. files is { added, modified, removed }, each a
{ path, checksum }[] with path relative to the workspace root; read
one with node:fs from resolve(workspaces.current.root, path). response
is a standard Response over the polled body, with the original’s
status, statusText, url, and redirected. change carries the diff:
the file lists, or { status, previous, current } for HTTP, where a body
that is not text is { bytes, contentType }.
Triggers
schedule(weekly).at({ weekday: "fri", hour: 16, minute: 0 }); // machine-local time
schedule(review({}, { target: "packages" })).every("6h"); // "30s" | "10m" | "6h" | "1d"
monitor("docs/**/*.md").do(({ change, log }) => {
if (change.kind === "files") log(change.modified);
});
monitor("https://status.example.com/api")
.every("5m")
.do(async ({ response, report }) => {
if (!response) return;
const { status } = await response.json();
report.milestone({ title: `Status: ${status}` });
});
monitor("https://tracker.example.com/latest")
.every("10m")
.do(async ({ response }) => {
if (!response) return;
return ship({}, { task: (await response.json()).title });
});
schedule(target)takes a named step or workflow, bare or as a childless lock. A lock carries its input, so there is no input field. A lock with children throwswrap it in a named workflow first; a nameless target throwshas no name..at(slot)or.every(interval)is the terminal call.weekdayis"sun"through"sat"or an array; omit it for every day.minutedefaults to zero. An interval is a positive integer withs,m,h, ord;"0s"is refused.monitor(source)takes one string.http://orhttps://is polled and fires when the response changes: each poll hashes the body, as stable JSON when it parses, as text for another textual type, as bytes otherwise, together with the status when it is not 2xx, and compares it with the last hash. A body that carries noise such as a timestamp fires every poll; readresponseand return early when nothing of interest changed. Anything else is a glob over the workspace root, polled;fileslists what was added, modified, or removed since the last poll..everydefaults to one minute..do(fn)is terminal..request(init)is for an HTTP source only (a glob throws at load): aRequestInitwithoutsignal, or a function returning one, sync or async, called at every poll. It is never part of the key, the stored state,list, or the feed. A401or403fails the poll and logs the status and the URL, never what was sent; any other status is a reading, so a move to404or500is a change. A network error fails the poll and keeps the last state.- A handler that returns a locked node starts it, attributed to the
monitor:
ship({}, { task })ornightly({}). Return a childless lock of a named definition. A bare definition is ignored;undefinedlaunches nothing. At most one node starts per tick. The launch is held as pending until a tick has started it, so a crash in between hands it out again; a target no longer registered is dropped after one failed tick; a started run that fails or is cancelled is not started again. - The first poll has no baseline. Every matching file is
added, or the first body counts as a change, so the handler runs once on first start. A glob that matches nothing fires nothing. - Keys. A schedule is keyed
slug(target)[-sha8(input)], a monitorslug(source)-sha6(source); an exact duplicate gets-2.listprints the keys. They name the state files androll <key>.
Composition and durability
A step owns the input, output stream, cancellation, and managed operations
for its attempt. A worktree callback changes the working directory inherited
by new commands and sessions. Session options can select a different host
cwd or continue a recorded conversation.
| Situation | Rule |
|---|---|
| Continue the same run | Reuse completed step results. |
| Resume a parked step | Replay its unfinished body from the top. Recorded managed operations are matched by call order. |
| Retry a failed attempt | Apply that step’s policy with fresh managed operations; completed children keep their results. |
| Launch again | Create a new run and execute fresh work. |
| Restart the host | Adopt eligible parked runs. Interrupted queued or running work is not adopted. |
| Rebuild workflow setup | Recreate the tree; recovered nodes retain their recorded literal input. |
Keep managed call order stable. Branch on recorded input when it controls those calls; branching on live values can change their replay positions. An explicit session turn can execute again during replay. A one-shot agent call can reuse its completed reply. Put an approval at the start of its step, with non-repeatable work in a completed child or after the answer.
The CLI saves snapshots at suspension, resumption, and settlement. Newer progress may not reach disk before a crash. The durability guide describes each operation; state lists adoption conditions.
Pausing or cancelling signals owned operations. Custom work must honor
signal; a step cannot settle until its attempt stops. The dashboard
supports steering, pause, and resume
without authored control code.
Exports
The main entry exports the factories above, the AI SDK’s Output, bail,
defineSkill, and dispose, plus these types:
| Group | Types |
|---|---|
| Instances | Agent, Runnable, WorkspaceInstance, SandboxInstance, ArtifactInstance |
| Work | StepDefinition, WorkflowDefinition, LockedNode |
| Resource definitions | AgentDefinition, AgentSettings, WorkspaceDefinition, SandboxDefinition, SandboxSpec, ArtifactDefinition, SkillSet |
| Agents | Agents, Session, SessionRef, SessionReply, TurnOptions |
| Workspaces and sandboxes | Workspaces, WorkspaceHandle, LoadedWorkspace, Sandboxes, SandboxHandle |
| Artifacts | Artifacts, ArtifactVersion |
| Execution | Context, Ask, Approval, Report, Stream, Run, Log, LogLevel |
| Triggers | Schedule, ScheduleBuilder, MonitorBuilder, CalendarSlot, Weekday, Trigger, MonitorContext, MonitorHandler, Change, FileChange, HttpChange |
@foundry.sh/marbles/prebuilt exports the
prebuilt step factories.
Not available yet
Do not write modules that depend on these.
- Race, branch, and loops between steps. Only series and parallel.
- A built-in model provider. No
providermeans the first installed CLI. - Recovery of a step that was mid-body when the process stopped. A step
parked on an
askor a pause does survive a quit, a crash, or a kill. - Scheduling a tree with children without wrapping it in
workflow. - Remote deployment commands, a remote dashboard, or distributed workers.
See Installation for package availability.