Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,12 @@ id=$(nan run --detach "upgrade the dependencies")
|---|---|---|
| `--ws NAME` | your only workspace | Workspace to run in. Required when you have more than one. |
| `--agent pi\|pi_gentle\|hermes` | `pi` | Agent to run: `pi_gentle` is Pi with Gentle Shell. The workspace must have it installed. |
| `--model M` | the agent's own | Model for the agent. Required with `--byok-secret`. |
| `--model M` | the agent's own | Model for the agent. Required with `--byok-secret` and `--ephemeral`. |
| `--byok-secret NAME` | NaN inference | Run on your own provider key: the name of a provider key secret saved in the portal. See below. |
| `--ephemeral` | | Run in a fresh, throwaway container instead of a workspace. See below. |
| `--repo OWNER/NAME` | | With `--ephemeral`: the GitHub repository the container clones. |
| `--github-token-secret NAME` | | With `--ephemeral` and `--repo`: the name of your secret holding a GitHub token, to clone a private repository. |
| `--size s\|m\|l` | `s` | With `--ephemeral`: the container size. |
| `--cwd PATH` | `/home/nan` | Folder in the workspace. |
| `--worktree` / `--no-worktree` | auto | Work on a separate branch, or in the folder itself. See below. |
| `--timeout D` | `30m` | Stop the run after this long, `1m` to `2h`. |
Expand Down Expand Up @@ -137,6 +141,41 @@ nan secrets ls # KIND provider_key
nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test"
```

**Ephemeral runs (no workspace).** `--ephemeral --repo OWNER/NAME --model ID`
runs the agent in a fresh, throwaway container: it clones the repository, runs
the agent with the model you name, streams like any run, and is destroyed at
the end. The container can only reach the internet over HTTP and HTTPS (no
SSH, no SMTP). For a private repository, `--github-token-secret NAME` names
one of your secrets holding a GitHub token that can read it (`nan secrets ls`):
it is used only to clone and is never given to the agent. Starting one needs your session or a platform token with the
`runs:ephemeral` scope; API keys cannot. Docs:
<https://nan.builders/docs/ephemeral-runs>.

| Size | Container | Price per started minute |
|---|---|---|
| `s` (default) | 1 vCPU, 4 GiB RAM, 10 GB disk | €0.003 |
| `m` | 2 vCPU, 8 GiB RAM, 16 GB disk | €0.006 |
| `l` | 4 vCPU, 12 GiB RAM, 20 GB disk | €0.012 |

A 14 s run is 1 minute. Free minutes every month: Community 100, Inference 300,
Premium 1000; they come with a membership (accounts without one have none).
Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free
minutes = 100 minutes of S, 50 of M or 25 of L). Ephemeral runs need a payment
method on file, free minutes included ("Add a payment method" in the portal,
Settings → Ephemeral runs; a subscription billed in USD is moved with "Switch
billing to EUR" there). While billing is not active, usage is
metered and shown, not charged. `nan runs ls` and `nan runs show` show each
ephemeral run's size and cost. The COST column of `nan runs ls` appears only
when an ephemeral run is listed, so scripts should read `nan runs ls --json`
rather than the columns.

```bash
nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test"
nan run --ephemeral --repo acme/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review the open PRs"
nan ephemeral usage # this month's minutes, cost, free S-minutes left, prices
nan ephemeral settings # your monthly spend cap and concurrent runs (change them in the browser)
```

**Queue.** If the workspace is already running as many runs as its size
allows, the run waits as `queued` and starts when one of them ends.

Expand Down Expand Up @@ -278,8 +317,8 @@ branch on it without parsing text:
| 4 | the workspace is not set up for it: agent not installed, no inference key, bad configuration |
| 10 | awaiting approval: an automation run whose proposed actions wait for you (`nan runs proposal <id>`, approve in the portal) |
| 64 | usage error (also: more than one workspace and no `--ws`) |
| 65 | not signed in, session expired, or not allowed |
| 69 | nan.builders unavailable, or the stream was lost after retries (the run goes on; the id is printed) |
| 65 | not signed in, session expired, or not allowed (also: a token without `runs:ephemeral`, ephemeral runs not available to you) |
| 69 | nan.builders unavailable, the stream was lost after retries (the run goes on; the id is printed), or an ephemeral run would pass your monthly spend cap |
| 75 | `--detach`, or Ctrl-C: the run was accepted and is still going |

Full reference, including the event types `nan runs logs --json` prints (one
Expand Down
28 changes: 21 additions & 7 deletions cmd/automations.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,14 +251,22 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
runs.SanitizeLine(a.ID),
runs.Truncate(runs.SanitizeLine(a.Name), 40),
runs.Truncate(runs.SanitizeLine(a.Workspace.Label()), 40),
automationWhere(a),
runs.SanitizeLine(a.Agent),
automationStatus(a),
runs.Truncate(approvalSummary(a.ApprovalKinds), 40))
}
return tw.Flush()
}

// automationWhere is where an automation's runs execute, for a table.
func automationWhere(a api.Automation) string {
if a.Executor == api.ExecutorEphemeral {
return "ephemeral " + sizeShort(a.Size)
}
return runs.Truncate(runs.SanitizeLine(a.Workspace.Label()), 40)
}

func automationStatus(a api.Automation) string {
if a.Enabled {
return "enabled"
Expand Down Expand Up @@ -313,18 +321,24 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error {
if a.Workspace != nil {
row("workspace", runs.NameAndID(a.Workspace.Name, a.Workspace.ID))
}
if a.Executor == api.ExecutorEphemeral {
row("executor", "ephemeral: a fresh container per run, size "+sizeLabel(a.Size))
}
agent := runs.SanitizeLine(a.Agent)
if a.Model != nil && *a.Model != "" {
agent += " · model " + runs.SanitizeLine(*a.Model)
}
row("agent", agent)
row("inference", inferenceLabel(a.Inference))
row("cwd", runs.SanitizeLine(a.Cwd))
worktree := "auto"
if a.GitIsolation != nil {
worktree = map[bool]string{true: "yes", false: "no"}[*a.GitIsolation]
if a.Executor != api.ExecutorEphemeral {
// An ephemeral run works in its fresh checkout: neither applies.
row("cwd", runs.SanitizeLine(a.Cwd))
worktree := "auto"
if a.GitIsolation != nil {
worktree = map[bool]string{true: "yes", false: "no"}[*a.GitIsolation]
}
row("worktree", worktree)
}
row("worktree", worktree)
if a.TimeoutSeconds > 0 {
row("timeout", (time.Duration(a.TimeoutSeconds) * time.Second).String())
}
Expand Down Expand Up @@ -514,7 +528,7 @@ func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json.
if name == "" {
name = runs.Truncate(runs.SanitizeLine(ref), 64)
}
fmt.Fprintf(env.stderr, "run %s of %s queued on %s; Ctrl-C to detach\n", run.ID, name, runs.SanitizeLine(run.Workspace.Name))
fmt.Fprintf(env.stderr, "run %s of %s queued %s, Ctrl-C to detach\n", run.ID, name, runPlace(run, &api.CreateRunRequest{}))
}
return follow(ctx, env, run.ID, 0, autoRunJSON, true)
}
Expand Down
28 changes: 28 additions & 0 deletions cmd/automations_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ API's automation object, for example:
exposure: actions
approval_kinds: [github_pr_review]

An ephemeral automation runs in a fresh, throwaway container instead of a
workspace: no workspace, a fresh checkout of its repository, and a model.

name: nightly review
executor: ephemeral
size: m
checkout: fresh
github_repo: acme/api
agent: pi
model: glm5.3
instructions: Review the open pull requests.

size is s (1 vCPU, 4 GiB), m (2 vCPU, 8 GiB) or l (4 vCPU, 12 GiB);
s when left out. A private repository needs an agent secret GITHUB_TOKEN
that can read it. ` + freeMinutesHelp + `.
Creating or running one with a platform token needs the runs:ephemeral
scope; see: nan ephemeral --help.

The full list of fields is in the API reference: https://nan.builders/docs/runs`

var automationsCreateCmd = &cobra.Command{
Expand Down Expand Up @@ -85,6 +103,10 @@ before pull request reviews are posted:

approval_kinds: []

or, for an ephemeral automation, to run it in a bigger container:

size: l

` + writeCredentialHelp + `

Docs: ` + writeDocs,
Expand Down Expand Up @@ -337,6 +359,9 @@ func resolveForWrite(ctx context.Context, env *runsEnv, ref string) (string, err
}

func doAutomationsCreate(ctx context.Context, env *runsEnv, body json.RawMessage) error {
if err := checkAutomationExecutor(body, true); err != nil {
return err
}
if err := requireWriteAPI(ctx, env, "automations"); err != nil {
return err
}
Expand All @@ -363,6 +388,9 @@ func warnAutomatic(env *runsEnv, a *api.Automation) {
}

func doAutomationsUpdate(ctx context.Context, env *runsEnv, ref string, body json.RawMessage, verb string) error {
if err := checkAutomationExecutor(body, false); err != nil {
return err
}
id, err := resolveForWrite(ctx, env, ref)
if err != nil {
return err
Expand Down
Loading
Loading