> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bonya.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Jobs

> Managed, tracked runs of a command or script

A **job** is a managed run of a command or script — on a new sandbox (created for the run and, by default, deleted once it ends) or an existing one. Jobs exist for the common case of "run this and tell me what happened," without you having to hold a sandbox handle open, poll exec yourself, or clean up afterward.

> [!NOTE]
>
> A job is not a replacement for [exec](https://docs.bonya.ai/concepts/exec.md) or [managed sessions](https://docs.bonya.ai/concepts/sessions.md) — it's a layer above them that also owns sandbox lifecycle for the run. Use exec or a session when you already have a sandbox and want to run something on it directly; use a job when you want the run tracked as its own resource, optionally on a sandbox created and torn down just for it.

## Creating a job

`POST /v1/job` either blocks for the result or starts durably and returns immediately, controlled by the `wait` body field (default `false`).

```shell cURL theme={null}
# run and wait for the result (wait is a body field, not a query param)
curl -s -X POST "https://api.bonya.ai/v1/job" \
  -H "Authorization: Bearer $BONYA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"new_sandbox": {"template_id": "bonya-dev"}, "cmd": ["./run-tests.sh"], "wait": true}'

# start durably and check later (wait defaults to false)
curl -s -X POST "https://api.bonya.ai/v1/job" \
  -H "Authorization: Bearer $BONYA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"existing_sandbox_id": "'"$SANDBOX_ID"'", "cmd": ["./long-migration.sh"]}'
```

```ts TypeScript theme={null}
const run = await client.runJob({
  newSandbox: { template: "bonya-dev" },
  cmd: ["./run-tests.sh"],
});
console.log(run.status, run.result?.exitCode);

const { runId } = await client.startJob({
  existingSandboxId: SANDBOX_ID,
  cmd: ["./long-migration.sh"],
});
```

```python Python theme={null}
from bonya import JobSandboxSpec, JobSpec

run = client.run_job(JobSpec(
    new_sandbox=JobSandboxSpec(template="bonya-dev"),
    cmd=("./run-tests.sh",),
))
print(run.status, run.result.exit_code)

run_id, already_running = client.start_job(JobSpec(
    existing_sandbox_id=SANDBOX_ID,
    cmd=("./long-migration.sh",),
))
```

```go Go theme={null}
run, err := client.RunJob(ctx, bonya.JobSpec{
    NewSandbox: &bonya.JobSandboxSpec{Template: "bonya-dev"},
    Cmd:        []string{"./run-tests.sh"},
})
fmt.Println(run.Status, run.Result.ExitCode)

runID, alreadyRunning, err := client.StartJob(ctx, bonya.JobSpec{
    ExistingSandboxID: sandboxID,
    Cmd:               []string{"./long-migration.sh"},
})
```

There's no `bonya job` CLI command — jobs are reached through REST or an SDK. **MCP:** `job_create` covers both modes — pass `wait: true` to block for the result or leave it `false` to get a `run_id` back immediately.

The request body (`JobRequest`) requires **exactly one** of `existing_sandbox_id`/`new_sandbox`, and **exactly one** of `cmd`/`script`:

**existing\_sandbox\_id** `string`

Run on a sandbox that already exists. Mutually exclusive with `new_sandbox`.

**new\_sandbox** `object`

Create a sandbox for this run. `{ template_id?, version?, name? }` — `template_id` may be omitted to use the deployment's configured default template, the same as [sandbox create](https://docs.bonya.ai/concepts/sandboxes.md#creating-a-sandbox).

**cmd** `string[]`

Argv to run. Mutually exclusive with `script`.

**script** `object`

`{ body_b64, interpreter?, args?, filename? }` — a script the caller wrote or uploaded, run instead of `cmd`. The body travels inline and is size-capped.

**pre\_run\_script** `object`

Optional script (same shape as `script`) executed after the sandbox is ready and before the job's own command or script. A non-zero exit stops the job before it runs.

**env** `object`

Extra environment variables for the job.

**path** `string`

Working directory for the job.

**disposition** `string` (default: delete)

`delete` or `keep` — what happens to a sandbox **this run created** once the run ends, however it ends (including cancellation). Not meaningful for `existing_sandbox_id`.

**resume\_if\_suspended** `boolean` (default: false)

Applies to `existing_sandbox_id` only. `false` makes a suspended target a failure rather than an implicit resume, since resuming is a billable action the caller may not have intended.

**wait** `boolean` (default: false)

`false` starts the run durably and returns immediately with a `run_id` (`202`). `true` blocks for the result, bounded by the caller's own request timeout (`200`).

Create also accepts an `Idempotency-Key` header — see [Idempotency](https://docs.bonya.ai/concepts/idempotency.md).

> [!WARNING]
>
> There is no endpoint to edit a run in place. To "edit and rerun" one: fetch its stored spec with `GET /v1/job/{run_id}`, change what you need, and `POST` it to `/v1/job` as a new run.

## Run lifecycle

A run's `status` moves through the states the job's own command or script produces, ending in a terminal state that carries a `result` (`exit_code`, captured output, and similar exec-style fields). Whether you passed `wait: true` or not, the same `result` shape appears once the run completes — `wait` only controls whether the create call itself blocks for it.

## Listing and inspecting runs

`GET /v1/job` is paginated, newest first; optionally filtered to `sandbox_id` or `schedule_id` (runs fired by a [job schedule](#job-schedules)). `GET /v1/job/{run_id}` returns the full run detail: the run summary plus the **stored job spec** that started it and an activity **timeline**. This is the endpoint to call before an edit-and-rerun, described above.

```shell cURL theme={null}
curl -s "https://api.bonya.ai/v1/job?sandbox_id=$SANDBOX_ID" \
  -H "Authorization: Bearer $BONYA_API_KEY"

curl -s "https://api.bonya.ai/v1/job/$RUN_ID" \
  -H "Authorization: Bearer $BONYA_API_KEY"
```

```ts TypeScript theme={null}
for await (const run of client.listJobRuns({ sandboxId: SANDBOX_ID })) {
  console.log(run.runId, run.status);
}

const detail = await client.getJobRun(runId);
console.log(detail.status, detail.spec, detail.timeline);
```

```python Python theme={null}
for run in client.list_job_runs(sandbox_id=SANDBOX_ID):
    print(run.run_id, run.status)

detail = client.get_job_run(run_id)
print(detail.status, detail.spec, detail.timeline)
```

```go Go theme={null}
runs, err := client.ListJobRuns(ctx, bonya.ListJobRunsOptions{SandboxID: sandboxID})

detail, err := client.GetJobRun(ctx, runID)
fmt.Println(detail.Status, detail.Spec, detail.Timeline)
```

There's no `bonya job` CLI command. **MCP:** `job_list` pages through runs; `job_get` returns one run's full detail.

## Canceling a run

`POST /v1/job/{run_id}/cancel` requests cancellation rather than forcibly terminating — the run's own cleanup (for example, deleting a sandbox it created, per `disposition`) still executes before the run reaches its terminal state.

```shell cURL theme={null}
curl -s -X POST "https://api.bonya.ai/v1/job/$RUN_ID/cancel" \
  -H "Authorization: Bearer $BONYA_API_KEY"
```

```ts TypeScript theme={null}
await client.cancelJobRun(runId); // requests cancellation; cleanup still runs
```

```python Python theme={null}
client.cancel_job_run(run_id)  # requests cancellation; cleanup still runs
```

```go Go theme={null}
err = client.CancelJobRun(ctx, runID) // requests cancellation; cleanup still runs
```

There's no `bonya job` CLI command. **MCP:** `job_cancel` requests cancellation the same way.

## Job schedules

A **job schedule** wraps a job spec with timing, so it runs on its own without a caller invoking create each time.

```shell cURL theme={null}
curl -s -X POST "https://api.bonya.ai/v1/job-schedule" \
  -H "Authorization: Bearer $BONYA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"interval_s": 3600, "job": {"new_sandbox": {"template_id": "bonya-dev"}, "cmd": ["./nightly-report.sh"]}}'
```

```ts TypeScript theme={null}
const schedule = await client.createJobSchedule(
  { intervalSeconds: 3600 },
  { newSandbox: { template: "bonya-dev" }, cmd: ["./nightly-report.sh"] },
);
console.log(schedule.scheduleId, schedule.nextRunAtUnixNanos);
```

```python Python theme={null}
from bonya import JobSandboxSpec, JobSpec, ScheduleSpec

schedule = client.create_job_schedule(
    ScheduleSpec(interval_seconds=3600),
    JobSpec(new_sandbox=JobSandboxSpec(template="bonya-dev"), cmd=("./nightly-report.sh",)),
)
print(schedule.schedule_id, schedule.next_run_at_unix_nanos)
```

```go Go theme={null}
schedule, err := client.CreateJobSchedule(ctx,
    bonya.ScheduleSpec{IntervalSeconds: 3600},
    bonya.JobSpec{NewSandbox: &bonya.JobSandboxSpec{Template: "bonya-dev"}, Cmd: []string{"./nightly-report.sh"}},
)
fmt.Println(schedule.ScheduleID, schedule.NextRunAtUnixNanos)
```

There's no `bonya job-schedule` CLI command. **MCP:** `job_schedule_create` covers this.

**cron\_expressions** `string[]`

Cron expressions to fire on. Exactly one of `cron_expressions`/`interval_s`/`run_at_unix_nanos` is required.

**interval\_s** `integer`

Fire every N seconds.

**run\_at\_unix\_nanos** `integer`

One-shot: a single future calendar time. Refused if it names a time in the past — a one-shot schedule is this field set, not a separate mechanism.

**time\_zone** `string`

IANA zone (e.g. `US/Pacific`) that `cron_expressions` is evaluated against. Empty is UTC.

**jitter\_s** `integer`

Random delay added to each fire, in seconds.

**overlap** `string` (default: skip)

What a fire does when the previous run from the same schedule is still going: `skip` (drop the new fire), `buffer_one`, or `allow_all`.

**paused** `boolean` (default: false)

Create the schedule already paused.

**job** `object` *required*

The job spec to run on each fire — same shape and same-field requirements as job create, above.

`PATCH /v1/job-schedule/{schedule_id}` **replaces** the whole schedule — timing and job spec together — so re-send every field you want to keep, not just the one you're changing.

`GET /v1/job-schedule/{schedule_id}` returns the schedule's timing, stored job spec, pause state, next run time, and its **recent run ids**: the last 10 fires (oldest first, including manual triggers), each a valid `GET /v1/job/{run_id}` id — enough to follow a schedule straight to its recent runs without a separate list call.

`POST /v1/job-schedule/{schedule_id}/pause` sets the paused state without deleting the schedule. `POST /v1/job-schedule/{schedule_id}/trigger` fires one run immediately, ignoring the schedule's own timing — useful for testing a schedule without waiting for it. `DELETE /v1/job-schedule/{schedule_id}` permanently removes it, without canceling any run currently in flight.

```shell cURL theme={null}
curl -s "https://api.bonya.ai/v1/job-schedule" \
  -H "Authorization: Bearer $BONYA_API_KEY"

curl -s -X POST "https://api.bonya.ai/v1/job-schedule/$SCHEDULE_ID/trigger" \
  -H "Authorization: Bearer $BONYA_API_KEY"
```

```ts TypeScript theme={null}
for await (const s of client.listJobSchedules()) {
  console.log(s.scheduleId);
}
const schedule = await client.getJobSchedule(scheduleId);

// updateJobSchedule replaces the whole schedule -- pass every field you
// want to keep, not just the one you're changing.
await client.updateJobSchedule(scheduleId, { intervalSeconds: 7200 }, jobSpec);

await client.setJobSchedulePaused(scheduleId, true, { note: "pausing for maintenance" });
await client.triggerJobSchedule(scheduleId); // fires one run now, ignoring timing
await client.deleteJobSchedule(scheduleId);
```

```python Python theme={null}
schedules = client.list_job_schedules()
schedule = client.get_job_schedule(schedule_id)

# update_job_schedule replaces the whole schedule -- pass every field you
# want to keep, not just the one you're changing.
schedule = client.update_job_schedule(schedule_id, ScheduleSpec(interval_seconds=7200), job_spec)

schedule = client.set_job_schedule_paused(schedule_id, True, note="pausing for maintenance")
client.trigger_job_schedule(schedule_id)  # fires one run now, ignoring timing
client.delete_job_schedule(schedule_id)
```

```go Go theme={null}
schedules, err := client.ListJobSchedules(ctx)
schedule, err = client.GetJobSchedule(ctx, scheduleID)

// UpdateJobSchedule replaces the whole schedule -- pass every field you
// want to keep, not just the one you're changing.
schedule, err = client.UpdateJobSchedule(ctx, scheduleID,
    bonya.ScheduleSpec{IntervalSeconds: 7200}, jobSpec)

schedule, err = client.SetJobSchedulePaused(ctx, scheduleID, true, "pausing for maintenance")
err = client.TriggerJobSchedule(ctx, scheduleID) // fires one run now, ignoring timing
err = client.DeleteJobSchedule(ctx, scheduleID)
```

There's no `bonya job-schedule` CLI command. **MCP:** `job_schedule_list` and `job_schedule_trigger` cover the calls above (plus `job_schedule_get`, `_update`, `_pause`, and `_delete` for the rest of the surface).

## Related

> [card] [Sandboxes](https://docs.bonya.ai/concepts/sandboxes.md)

> [card] [Templates](https://docs.bonya.ai/concepts/templates.md)

> [card] [Exec](https://docs.bonya.ai/concepts/exec.md)

> [card] [Idempotency](https://docs.bonya.ai/concepts/idempotency.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.