Seeds and actions (Beta)
Typed actions, the script contract, persona inputs, seed chains and ${step.output} references in Sandboxes v2.
An action is a script with a typed input and output schema, declared in cofactor.sandbox.yaml. It is the one way data gets into a Sandboxes v2 sandbox: an environment's seeds call actions, and so do tests, agents and cofactor sandbox run. An environment's seeds list is just an ordered sequence of action (and emulator operation) calls.
The examples use an invented project tracker with workspaces, members (roles: owner, member, viewer), projects and tasks.
01Declaring an action
actions:
create-project:
description: A new project in a workspace, created by the given persona.
run: sandbox/actions/create-project.sh
input:
workspace_id: { type: string, required: true }
name: { type: string, required: true }
as: { type: persona, required: true, description: Who creates it }
output:
project_id: { type: string }
timeout: 60
Every field and its rules are in Sandbox definition → actions. In short: description and run are required; input and output are maps of typed properties; timeout is 1–1800 seconds (default 60).
Input types are string, number, integer, boolean and persona. Output types are string, number, integer and boolean. An input can also have description, required, default, enum and sensitive (masked wherever action input is logged or returned).
02The action contract
The script is run with bash, from the definition's directory (GitHub-delivered) or the image's WORKDIR (pushed images), inside the sandbox, so it reaches the app on localhost.
What it receives, as environment variables:
| Variable | Value |
|---|---|
INPUT_<NAME> | Each input, the name uppercased: as arrives as INPUT_AS. Numbers are written as text; booleans as true or false. A persona input carries the persona's name, such as owner. Defaults are applied before the script runs. |
The definition's env: | Resolved with this sandbox's own param values, with the claimed environment's env: on top. |
COFACTOR_PARAM_<NAME> | Every param, the name uppercased. |
COFACTOR_PERSONAS | Every persona's login: { "<persona>": { "email": "…", "password": "…" } }. |
With object_storage declared, it also gets the provider's client variables (see Object storage).
What it returns: a JSON object on the last line of stdout that starts with {. Send every progress message, echo and intermediate response to stderr; a stray {…} line printed last is taken as the result.
When it fails:
- the script exits non-zero, or is killed at its
timeout; - it prints no parseable JSON object line;
- its result is missing a declared output key, or a value has the wrong type. (Extra keys are allowed.)
A failed run reports its error and the end of the script's stderr, which is where the real error message usually is.
A complete action:
#!/usr/bin/env bash
# sandbox/actions/create-project.sh
set -euo pipefail
login=$(jq -c --arg p "$INPUT_AS" '.[$p] | {email, password}' <<<"$COFACTOR_PERSONAS")
jar=$(mktemp)
curl -fsS -c "$jar" -H 'content-type: application/json' \
-d "$login" http://127.0.0.1:8080/api/auth/login >&2
id=$(curl -fsS -b "$jar" -H 'content-type: application/json' \
-d "$(jq -nc --arg n "$INPUT_NAME" '{name: $n}')" \
"http://127.0.0.1:8080/api/workspaces/$INPUT_WORKSPACE_ID/projects" | jq -er '.id')
jq -nc --arg id "$id" '{project_id: $id}'
03Personas in actions
An action that performs a real operation takes the acting persona as an input (as, of type persona). The script reads that persona's email and password from COFACTOR_PERSONAS and signs in through the app's real login endpoint, the same way a user would, keeping the session cookie for its next calls (the first lines of the script above).
- A
personainput only accepts a persona the definition declares. Anything else is refused before the script runs. - The persona must already have a role that allows the operation (from a seed such as
add-member, or because its account already has it). - If your app's session cookie is
Secure(or__Host-prefixed) and your HTTP client won't send it back over plain http, call the app through its public port URL (a{ port }param) instead of localhost.
04Running an action
On a claimed, ready sandbox:
cofactor sandbox run <id> create-project \ --input workspace_id=<acme.workspace_id> \ --input name=Temp \ --input as=member
{ "project_id": "…" }
--input key=valueis repeatable, and each value is coerced to the declared type (integerandnumberparse as numbers;booleanistrueorfalse).--json-input '{"name": "Temp", "as": "member"}'passes the whole input as JSON.- Invalid input (missing, wrong type, outside
enum, an undeclared persona) prints the issues and the input schema, and runs nothing. --jsonprints the whole run: status, output, exit code, the tail of stdout and stderr, and timing. See Sandbox CLI → run.
cofactor sandbox envs and cofactor sandbox show list every action with its inputs and outputs.
05Seeds
Each environment's seeds run once per version (commit or push) and environment, while that environment's ready snapshot is built: after prepare, before the snapshot is captured. Every claim of that version and environment starts from their result, data included.
environments:
default:
description: |
Workspace "Acme": owner is its owner, member a member,
viewer a read-only viewer. No projects yet.
seeds:
- id: acme
action: create-workspace
input: { name: Acme, as: owner }
- id: add_member
action: add-member
input: { workspace_id: "${acme.workspace_id}", persona: member, role: member, as: owner }
- id: add_viewer
action: add-member
input: { workspace_id: "${acme.workspace_id}", persona: viewer, role: viewer, as: owner }
free-plan:
extends: default
description: Same as default, but Acme is moved to the Free plan (3-project limit).
seeds:
- id: downgrade
action: set-plan
input: { workspace_id: "${acme.workspace_id}", plan: free }
Each seed step is { id, action, input }:
id: lowercase letters, digits and_; unique across the whole resolved chain, because a child's steps can reference its parent's.action: a declared action's name, or<plugin>.<op>for an emulator operation.input: literal values, or references to an earlier step's output.
Steps run in the order they're written. With extends, the parent's seeds run first, then the child's own. Each step's output is reported as the claim's seed outputs (acme.workspace_id above), the same for every copy of the snapshot.
06${step.output} references
${<step id>.<output key>} passes an earlier step's output to a later step's input.
A reference that is the entire value keeps the output's real type:
input: { workspace_id: "${acme.workspace_id}" } # exactly acme's workspace_id
A reference embedded in a longer string becomes text:
input: { name: "Board for ${acme.workspace_id}" } # a string
cofactor sandbox validate checks every reference: it must name a step that runs earlier in the chain, one of that step's declared output keys, and an output that is a string, number or boolean.
These are the seed-step references. ${params.name} is a different thing: it substitutes a param into the top-level env: (see Ports and params).
07Emulator operations
When the definition declares an emulation plugin that has operations, they appear alongside your own actions, named <plugin>.<op>, and run exactly like an action: from the CLI, or as a seed step. Today there is one:
| Operation | Input | Output |
|---|---|---|
email.sent-emails | none | emails: every email captured since boot or the last reset |
cofactor sandbox run <id> invite-user \ --input workspace_id=<acme.workspace_id> \ --input email=new@example.test --input role=member --input as=owner cofactor sandbox run <id> email.sent-emails
emails is a list, so a later seed step can't reference it with ${…}.
08Seeds vs. actions a test calls itself
Seeds are shared, thin, generic base data: the handful of things nearly every test needs before it can start, such as a workspace and one member per role.
An action a test calls itself creates data specific to that one test. A test about archiving a project creates its own project with create-project, then archives it; that project doesn't live in a shared seed. A shared seed that every test depends on becomes something every other test can break, and it slows every new version's first claim.
Rule of thumb: if removing this data would break tests that aren't about it, it's a seed. If only one test's own scenario needs it, it's an action call inside that test.
09What makes a good action
- Typed inputs and outputs. The input schema is how a test or an agent calls it correctly; the output schema is what the next step chains from.
- Fast. Seeds slow every new version's first claim; calls slow every test that makes them.
- Returns the ids a test needs next.
create-projectreturnsproject_id, not "ok". - Safe to call more than once, or unique by its input. Either idempotent (
set-planwith the same plan changes nothing), or a distinct call makes a distinct result (create-projectwith a new name). - Explicit about who acts. Every action that performs a business operation takes
as: <persona>, rather than assuming "the admin". It then has no hidden dependency on some other seed having established who's in charge, and a test can call it as a viewer to check the operation is refused. - Goes through the app's own API when the data is encrypted or has side effects.
invite-usercalls the real invite endpoint, so the email is actually sent (and observable withemail.sent-emails). A raw insert would skip the send, or anything the app hashes or encrypts on write. - Goes to the database only for rows with no encryption and no side effects, and only when there's no API for it, such as a plan column that in production only an internal billing process sets.
10Anti-patterns
- A giant do-everything seed. One step that creates a workspace, three members, two projects and their tasks is hard to reuse and hard to debug when it fails halfway. Split it into actions and chain them with
${id.key}. - An action that silently acts as an admin. A test can't know or change who performed the operation, and can't reuse it to check that a viewer is refused.
- Hardcoded ids. Every id a later step needs should come from an earlier step's declared output.
- Undeclared ordering. A script that assumes some other seed already ran, without taking what it needs as an input, breaks with no local sign of why when the seed order changes.
- Output anywhere but the last
{line of stdout. A debuggingechoon stdout corrupts the parsed result.
11When a seed fails
Any step's failure fails that build of the version's ready snapshot for that environment, and the half-seeded builder is discarded. Every claim that was waiting on that build fails with the same error. A claim made after the failure builds the snapshot again, so a seed that fails every time keeps failing claims until you fix it and push or commit again.
To debug it:
- Read the error. The claim (or
verify-env) reports which step failed, its action, and the script's stderr. - Claim a sandbox without the failing step: an environment that stops short of it (such as the parent of the failing environment), so you have a live sandbox to work on.
- Run the failing action by hand with
cofactor sandbox run <id> <action> --input k=v …, using the inputs the seed step would have resolved (substitute the actual values for any${id.key}references). Iterate on the script without re-claiming. - Fix the script or the seed, push or commit, and run
cofactor sandbox verify-envto confirm the full chain.