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

# Background commands

> Start a command that runs longer than 10 minutes, read its output, attach to it, and stop it.

A synchronous command stops at its `timeoutSeconds` limit. The maximum is 600 seconds (10 minutes). For a longer job, start the command detached, then poll for its result. Examples are builds, installs and data processing.

This replaces the `nohup ... &` pattern for simple cases. You do not keep an SSH session open. You do not write a unit file.

The flow is: start, then poll, then collect.

## Start a command and read its status

<CodeGroup>
  ```bash CLI theme={null}
  pid="$(boat exec bx_f7k2q9hd --detach "npm run build" | jq -r .processId)"
  boat exec bx_f7k2q9hd --status "$pid"
  ```

  ```bash curl theme={null}
  curl -sS -X POST "$BOAT_API_BASE/sandboxes/$BOAT_ID/commands" \
    -H "Authorization: Bearer $BOAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"command":"npm run build","detached":true}'
  # {"type":"command.started","processId":1234,"pid":1234,"logPath":"/home/user/.ascii/processes/1234.log",...}

  curl -sS "$BOAT_API_BASE/sandboxes/$BOAT_ID/commands/1234" \
    -H "Authorization: Bearer $BOAT_API_KEY"
  # {"type":"command.status","running":false,"exitCode":0,"stdout":"...","stderr":"...",...}
  ```

  ```ts TypeScript theme={null}
  const started = await sandbox.command({
    sandboxId,
    command: "npm run build", detached: true,
  });

  const status = await sandbox.commandStatus({ sandboxId, processId: started.processId });
  ```

  ```python Python theme={null}
  from boat_sdk.models.command_request import CommandRequest

  started = sandbox.command(sandbox_id, CommandRequest(command="npm run build", detached=True))

  status = sandbox.command_status(sandbox_id, started.process_id)
  ```
</CodeGroup>

* The command continues on the sandbox after the start call returns.
* Boat appends stdout and stderr to `~/.ascii/processes/<pid>.log` on the sandbox.
* The status call gives `running`, the `exitCode` when the command ends, and the last part of each log.

## Read only new output

1. Give `stdoutOffset` and `stderrOffset` to the status call. Start at 0.
2. The answer holds only the output written after those byte offsets.
3. The answer also holds `stdoutNextOffset` and `stderrNextOffset`. Use them in the next call.
4. When `more` is `true`, call again immediately.

<CodeGroup>
  ```bash CLI theme={null}
  # The CLI follows for you: see Attach below.
  boat exec bx_f7k2q9hd --attach "$pid"
  ```

  ```bash curl theme={null}
  curl -sS "$BOAT_API_BASE/sandboxes/$BOAT_ID/commands/1234?stdoutOffset=0&stderrOffset=0" \
    -H "Authorization: Bearer $BOAT_API_KEY"
  # {"type":"command.status","running":true,"stdout":"compiling...\n","stdoutNextOffset":13,"stderrNextOffset":0,"more":false,...}
  ```

  ```ts TypeScript theme={null}
  let stdoutOffset = 0, stderrOffset = 0;
  for (;;) {
    const r: any = await sandbox.commandStatus({ sandboxId, processId: started.processId, stdoutOffset, stderrOffset });
    process.stdout.write(r.stdout);
    process.stderr.write(r.stderr);
    stdoutOffset = r.stdoutNextOffset;
    stderrOffset = r.stderrNextOffset;
    if (!r.running && !r.more) break;
    if (!r.more) await new Promise((ok) => setTimeout(ok, 1000));
  }
  ```

  ```python Python theme={null}
  import sys, time

  out_offset = err_offset = 0
  while True:
      r = sandbox.command_status(sandbox_id, started.process_id, stdout_offset=out_offset, stderr_offset=err_offset)
      sys.stdout.write(r.stdout)
      sys.stderr.write(r.stderr)
      out_offset, err_offset = r.stdout_next_offset, r.stderr_next_offset
      if not r.running and not r.more:
          break
      if not r.more:
          time.sleep(1)
  ```
</CodeGroup>

## Attach to a background process

Attach opens one stream. The stream sends the output while the command writes it, until the command ends. Use attach instead of polling.

The API allows about 1,000 requests per minute per IP address. One poll per second for each of many processes reaches this limit. One open stream per process does not.

Each line of the stream is one JSON object:

| Line | When |
| - | - |
| `attached` | First. |
| `stdout` / `stderr` | Each chunk of output, with its `nextOffset`. |
| `heartbeat` | After 15 seconds with no output. |
| `reconnect` | After 10 minutes. The stream ends. The line holds `stdoutOffset` and `stderrOffset`. |
| `exit` | Last. It holds the exit code. |

After a `reconnect` line, open the stream again with those offsets. You get no gap and no repeat, for as long as the command runs. The CLI reconnects for you.

<CodeGroup>
  ```bash CLI theme={null}
  boat exec bx_f7k2q9hd --attach "$pid"
  # prints the output from its start, then exits with the command's exit code
  ```

  ```bash curl theme={null}
  curl -sSN "$BOAT_API_BASE/sandboxes/$BOAT_ID/commands/1234/stream?stdoutOffset=0&stderrOffset=0" \
    -H "Authorization: Bearer $BOAT_API_KEY"
  # {"type":"attached","processId":1234,"stdoutOffset":0,"stderrOffset":0}
  # {"type":"stdout","data":"compiling...\n","nextOffset":13}
  # {"type":"exit","status":"exited","exitCode":0,"signal":null,"stdoutOffset":13,"stderrOffset":0}
  ```

  ```ts TypeScript theme={null}
  let offsets = { stdout: 0, stderr: 0 };
  for (let done = false; !done; ) {
    const res = await fetch(`${BOAT_API_BASE}/sandboxes/${sandboxId}/commands/${pid}/stream?stdoutOffset=${offsets.stdout}&stderrOffset=${offsets.stderr}`,
      { headers: { Authorization: `Bearer ${BOAT_API_KEY}` } });
    const lines = res.body!.pipeThrough(new TextDecoderStream());
    let buf = "";
    for await (const chunk of lines) {
      buf += chunk;
      let i;
      while ((i = buf.indexOf("\n")) >= 0) {
        const f = JSON.parse(buf.slice(0, i));
        buf = buf.slice(i + 1);
        if (f.type === "stdout" || f.type === "stderr") { process[f.type].write(f.data); offsets[f.type] = f.nextOffset; }
        if (f.type === "reconnect") offsets = { stdout: f.stdoutOffset, stderr: f.stderrOffset };
        if (f.type === "exit" || f.type === "error") done = true;
      }
    }
  }
  ```

  ```python Python theme={null}
  import json, sys, requests

  offsets = {"stdout": 0, "stderr": 0}
  done = False
  while not done:
      url = f"{BOAT_API_BASE}/sandboxes/{sandbox_id}/commands/{pid}/stream"
      params = {"stdoutOffset": offsets["stdout"], "stderrOffset": offsets["stderr"]}
      with requests.get(url, params=params, headers={"Authorization": f"Bearer {BOAT_API_KEY}"}, stream=True) as r:
          for line in r.iter_lines():
              if not line:
                  continue
              f = json.loads(line)
              if f["type"] in ("stdout", "stderr"):
                  getattr(sys, f["type"]).write(f["data"])
                  offsets[f["type"]] = f["nextOffset"]
              elif f["type"] == "reconnect":
                  offsets = {"stdout": f["stdoutOffset"], "stderr": f["stderrOffset"]}
              elif f["type"] in ("exit", "error"):
                  done = True
  ```
</CodeGroup>

Polling and attach both read the sandbox live. Thus the sandbox must run.

* A sandbox on an older agent answers `409 agent_outdated` until its next agent upgrade.
* To read output after the sandbox stops, use the [command history](/long-running-tasks/command-history).

## Stop a background command

Stop the command by its process id. This also stops every process that the command started.

1. Boat sends `SIGTERM` to the whole process group.
2. After 5 seconds, Boat sends `SIGKILL` to the processes that remain.
3. The response holds the [history record](/long-running-tasks/command-history) of the command, with status `killed` and its last output.

<CodeGroup>
  ```bash CLI theme={null}
  boat exec bx_f7k2q9hd --kill "$pid"
  ```

  ```bash curl theme={null}
  curl -sS -X DELETE "$BOAT_API_BASE/sandboxes/$BOAT_ID/commands/1234" \
    -H "Authorization: Bearer $BOAT_API_KEY"
  # {"type":"command.stopped","processId":1234,"running":false,"signaled":true,"command":{"commandId":"cmd_...","status":"killed",...}}
  ```

  ```ts TypeScript theme={null}
  await sandbox.stopCommand({ sandboxId, processId: started.processId });
  ```

  ```python Python theme={null}
  sandbox.stop_command(sandbox_id, started.process_id)
  ```
</CodeGroup>

## When the agent restarts

The process runs under the systemd user manager of the sandbox user. Thus a restart of the agent of the sandbox does not interrupt it. A platform upgrade is an example of such a restart.

But the agent forgets the process when it restarts:

* The status changes to `lost`.
* `running` becomes a best-effort check.
* Boat no longer reports `exitCode`.
* Boat reads the logs from the files on disk.

Detached processes do not continue after a stop, resume or fork. For a process that must come back by itself, run it as an [always-on service](/long-running-tasks/always-on-services).

## Timeouts and retries

Synchronous commands accept `timeoutSeconds` from 1 to 600. The default is 30. Boat refuses values outside that range with a 400 `invalid_timeout` error.

Boat never retries a command call automatically. A command is not idempotent. After a timeout (`retryable: false`) or a network failure during the call, the command can already run on the sandbox. You decide what to do next.


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