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

# Quickstart

> Send your first dock command against the sandbox — no signup, no production credential — in six runnable steps.

# Quickstart

This walks through the dock-control surface end to end against the **sandbox** — a zero-signup reference implementation of the reserved dock-control API. Every sample below is a self-contained TypeScript file: copy it, run it with `tsx`, and it talks to the real sandbox over plain `fetch`. No SDK, no shared helper library.

## What you need

* **Base URL:** `https://outpost.earthity.com/api/sandbox`. The sandbox serves the exact same paths, request/response shapes, and error catalog as production — only the base URL and the dock ids differ.
* **Auth:** an "instant key" — any well-formed `Authorization: Bearer spk_<anything>` header is accepted. Nothing is validated against a store or persisted; it exists purely so you exercise a real `401` path before you have a production credential.
* **Compressed timeline:** the sandbox resolves a command in about a second (`queued` for the first \~300ms, `executing` until \~1200ms, terminal after) instead of the \~45s a real dock stroke takes. Poll intervals and deadlines below are sized for that compressed timeline — widen them against a real dock.

Every sample targets a different **magic dock id** — a deterministic dock that always behaves the same way, so you can exercise every status/outcome/error with zero physical hardware. The full reference table is at the bottom of this page.

## Send a command

```ts theme={null}
/*
 * Outpost quickstart sample 01 — send a command.
 *
 * Sends an "open" actuation command to a sandbox dock and inspects the 202
 * response: a commandId to poll later, and a Location header pointing at
 * that command's own resource. Every command send MUST carry an
 * Idempotency-Key header — the sandbox (and production) reject a request
 * that omits it (see sample 06 for the 400 that produces).
 *
 * Self-contained: copy this whole file. No imports beyond the `fetch` you
 * already have.
 */

export type SampleContext = { baseUrl: string; apiKey: string; fetch: typeof fetch }

const DOCK_ID = 'sbx-endstop'

export async function run(ctx: SampleContext): Promise<string[]> {
  const lines: string[] = []

  const res = await ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${ctx.apiKey}`,
      'Idempotency-Key': 'quickstart-01-send-command',
    },
    body: JSON.stringify({ command: 'open' }),
  })
  const body = (await res.json()) as { commandId: string; status: string }
  const location = res.headers.get('location')
  // Compare the raw ids IN-SAMPLE, before either one is printed: the
  // Location header's own trailing path segment is that same command's id.
  // Comparing raw values (not the printed/normalized text) is what makes
  // this a real check rather than something that'd stay true even if the
  // two ids diverged onto two different-but-similarly-shaped uuids.
  const locationCommandId = location?.split('/').pop() ?? null

  lines.push(`POST /api/v1/docks/${DOCK_ID}/commands -> ${res.status}`)
  lines.push(`commandId: ${body.commandId}`)
  lines.push(`status: ${body.status}`)
  lines.push(`Location: ${location}`)
  lines.push(`Location points at the returned commandId: ${locationCommandId === body.commandId ? 'yes' : 'no'}`)

  return lines
}

if (require.main === module) {
  const ctx: SampleContext = {
    baseUrl: process.env.OUTPOST_BASE_URL ?? 'https://outpost.earthity.com/api/sandbox',
    apiKey: process.env.OUTPOST_API_KEY ?? 'spk_quickstart',
    fetch: globalThis.fetch,
  }
  run(ctx)
    .then(lines => {
      for (const line of lines) console.log(line)
    })
    .catch(err => {
      console.error(err)
      process.exit(1)
    })
}
```

**Expected output** (command ids will differ):

```text theme={null}
POST /api/v1/docks/sbx-endstop/commands -> 202
commandId: cmd_<uuid>
status: queued
Location: /api/sandbox/api/v1/docks/sbx-endstop/commands/cmd_<uuid>
Location points at the returned commandId: yes
```

A command send returns `202 Accepted` immediately — the physical stroke hasn't happened yet. The body carries a `commandId` to poll, and the `Location` header points at that same command's own resource. The `Idempotency-Key` header is **required**: omit it and the request is rejected with `400 validation_failed` (see [error anatomy](#error-anatomy) below).

## Poll to a terminal outcome

```ts theme={null}
/*
 * Outpost quickstart sample 02 — poll to a terminal outcome.
 *
 * A 202 from a command send is not the final word — the command is
 * `queued`, then `executing`, then terminal (`succeeded`/`failed` + an
 * `outcome`). This polls GET .../commands/{commandId} on a short interval
 * until it observes a terminal status, or a deadline elapses.
 *
 * The sandbox's compressed timeline (queued <~300ms, executing until
 * ~1200ms) exists so this loop resolves in about a second instead of the
 * ~45s a real dock stroke takes. Use whatever interval/deadline suits your
 * own polling budget against a real dock.
 *
 * Self-contained: copy this whole file. No imports beyond the `fetch` you
 * already have.
 */

export type SampleContext = { baseUrl: string; apiKey: string; fetch: typeof fetch }

const DOCK_ID = 'sbx-kick'
const POLL_INTERVAL_MS = 100
const POLL_DEADLINE_MS = 5000

export async function run(ctx: SampleContext): Promise<string[]> {
  const lines: string[] = []

  const postRes = await ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${ctx.apiKey}`,
      'Idempotency-Key': 'quickstart-02-poll-status',
    },
    body: JSON.stringify({ command: 'open' }),
  })
  const posted = (await postRes.json()) as { commandId: string; status: string }
  lines.push(`POST /api/v1/docks/${DOCK_ID}/commands -> ${postRes.status}`)
  lines.push(`commandId: ${posted.commandId}`)

  let observedNonTerminal = false
  let finalStatus = ''
  let finalOutcome = ''
  const deadline = Date.now() + POLL_DEADLINE_MS

  for (;;) {
    const res = await ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands/${posted.commandId}`, {
      headers: { Authorization: `Bearer ${ctx.apiKey}` },
    })
    const body = (await res.json()) as { status: string; outcome?: string }

    if (body.status === 'succeeded' || body.status === 'failed') {
      finalStatus = body.status
      finalOutcome = body.outcome ?? ''
      break
    }
    observedNonTerminal = true
    if (Date.now() >= deadline) {
      finalStatus = body.status
      break
    }
    await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
  }

  lines.push(`observed a non-terminal status before completion: ${observedNonTerminal ? 'yes' : 'no'}`)
  lines.push(`final status: ${finalStatus}, outcome: ${finalOutcome}`)

  return lines
}

if (require.main === module) {
  const ctx: SampleContext = {
    baseUrl: process.env.OUTPOST_BASE_URL ?? 'https://outpost.earthity.com/api/sandbox',
    apiKey: process.env.OUTPOST_API_KEY ?? 'spk_quickstart',
    fetch: globalThis.fetch,
  }
  run(ctx)
    .then(lines => {
      for (const line of lines) console.log(line)
    })
    .catch(err => {
      console.error(err)
      process.exit(1)
    })
}
```

**Expected output** (command ids will differ):

```text theme={null}
POST /api/v1/docks/sbx-kick/commands -> 202
commandId: cmd_<uuid>
observed a non-terminal status before completion: yes
final status: succeeded, outcome: KICK
```

A command moves through `queued` -> `executing` -> a terminal state (`succeeded` or `failed`, each carrying an `outcome`). Poll `GET .../commands/{commandId}` on a short interval until you observe a terminal status, or your own deadline elapses. Never assume the first `GET` after a `202` is already terminal — treat every non-terminal response as expected, not an error.

## Failure is a terminal outcome, not an HTTP error

```ts theme={null}
/*
 * Outpost quickstart sample 03 — failure is a terminal outcome, not an HTTP
 * error.
 *
 * `sbx-obstructed` always resolves `failed`/`OBSTRUCTED` — the physical
 * stroke didn't complete. The GET that reports that is still an HTTP 200:
 * the command lifecycle itself succeeded (queued -> executing -> terminal),
 * independent of whether the actuation did. Only the response BODY's
 * `status`/`outcome` fields tell you the stroke failed — check those, not
 * the HTTP status code, to decide whether a command worked.
 *
 * Self-contained: copy this whole file. No imports beyond the `fetch` you
 * already have.
 */

export type SampleContext = { baseUrl: string; apiKey: string; fetch: typeof fetch }

const DOCK_ID = 'sbx-obstructed'
const POLL_INTERVAL_MS = 100
const POLL_DEADLINE_MS = 5000

export async function run(ctx: SampleContext): Promise<string[]> {
  const lines: string[] = []

  const postRes = await ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${ctx.apiKey}`,
      'Idempotency-Key': 'quickstart-03-handle-failure',
    },
    body: JSON.stringify({ command: 'open' }),
  })
  const posted = (await postRes.json()) as { commandId: string; status: string }
  lines.push(`POST /api/v1/docks/${DOCK_ID}/commands -> ${postRes.status}`)
  lines.push(`commandId: ${posted.commandId}`)

  let terminalHttpStatus = 0
  let finalStatus = ''
  let finalOutcome = ''
  const deadline = Date.now() + POLL_DEADLINE_MS

  for (;;) {
    const res = await ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands/${posted.commandId}`, {
      headers: { Authorization: `Bearer ${ctx.apiKey}` },
    })
    const body = (await res.json()) as { status: string; outcome?: string }

    if (body.status === 'succeeded' || body.status === 'failed') {
      terminalHttpStatus = res.status
      finalStatus = body.status
      finalOutcome = body.outcome ?? ''
      break
    }
    if (Date.now() >= deadline) {
      terminalHttpStatus = res.status
      finalStatus = body.status
      break
    }
    await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
  }

  lines.push(`GET commands/${posted.commandId} at terminal state -> HTTP ${terminalHttpStatus}`)
  lines.push(`final status: ${finalStatus}, outcome: ${finalOutcome}`)
  lines.push('a "failed" terminal outcome is still HTTP 200 — the command lifecycle succeeded even though the stroke did not')

  return lines
}

if (require.main === module) {
  const ctx: SampleContext = {
    baseUrl: process.env.OUTPOST_BASE_URL ?? 'https://outpost.earthity.com/api/sandbox',
    apiKey: process.env.OUTPOST_API_KEY ?? 'spk_quickstart',
    fetch: globalThis.fetch,
  }
  run(ctx)
    .then(lines => {
      for (const line of lines) console.log(line)
    })
    .catch(err => {
      console.error(err)
      process.exit(1)
    })
}
```

**Expected output** (command ids will differ):

```text theme={null}
POST /api/v1/docks/sbx-obstructed/commands -> 202
commandId: cmd_<uuid>
GET commands/cmd_<uuid> at terminal state -> HTTP 200
final status: failed, outcome: OBSTRUCTED
a "failed" terminal outcome is still HTTP 200 — the command lifecycle succeeded even though the stroke did not
```

`sbx-obstructed` always resolves `failed`/`OBSTRUCTED` — the actuation didn't complete. The `GET` that reports that is still `HTTP 200`: the command *lifecycle* succeeded (it reached a terminal state), independent of whether the actuation itself did. Decide success or failure from the response **body**'s `status`/`outcome` fields, never from the HTTP status code alone.

## Handle `dock_busy`

```ts theme={null}
/*
 * Outpost quickstart sample 04 — handle `dock_busy`.
 *
 * A dock only runs one command at a time. `sbx-busy` always reports a
 * command already in flight, regardless of the Idempotency-Key used —
 * demonstrating the 409 `dock_busy` problem+json body a real dock returns
 * when your command arrives while its previous one is still executing.
 * The right response is to back off and retry later with a FRESH
 * Idempotency-Key (this one never clears, so retrying it here would just
 * 409 again).
 *
 * Self-contained: copy this whole file. No imports beyond the `fetch` you
 * already have.
 */

export type SampleContext = { baseUrl: string; apiKey: string; fetch: typeof fetch }

const DOCK_ID = 'sbx-busy'

export async function run(ctx: SampleContext): Promise<string[]> {
  const lines: string[] = []

  const res = await ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${ctx.apiKey}`,
      'Idempotency-Key': 'quickstart-04-dock-busy',
    },
    body: JSON.stringify({ command: 'open' }),
  })
  const body = (await res.json()) as { type: string; status: number; title: string; code: string }

  lines.push(`POST /api/v1/docks/${DOCK_ID}/commands -> ${res.status}`)
  lines.push(`type: ${body.type}`)
  lines.push(`title: ${body.title}`)
  lines.push(`code: ${body.code}`)
  lines.push('sbx-busy always returns 409 dock_busy, regardless of the Idempotency-Key used')

  return lines
}

if (require.main === module) {
  const ctx: SampleContext = {
    baseUrl: process.env.OUTPOST_BASE_URL ?? 'https://outpost.earthity.com/api/sandbox',
    apiKey: process.env.OUTPOST_API_KEY ?? 'spk_quickstart',
    fetch: globalThis.fetch,
  }
  run(ctx)
    .then(lines => {
      for (const line of lines) console.log(line)
    })
    .catch(err => {
      console.error(err)
      process.exit(1)
    })
}
```

**Expected output** (command ids will differ):

```text theme={null}
POST /api/v1/docks/sbx-busy/commands -> 409
type: https://docs.earthity.com/errors/dock_busy
title: Dock is busy
code: dock_busy
sbx-busy always returns 409 dock_busy, regardless of the Idempotency-Key used
```

A dock runs one command at a time. Sending a command to a dock that already has one in flight returns `409 dock_busy` — a problem+json body, not a 2xx. The right response is to back off and retry later with a **fresh** `Idempotency-Key`; retrying with the same key just replays into the same busy dock.

## Retry safely with idempotency

```ts theme={null}
/*
 * Outpost quickstart sample 05 — retry safely with idempotency.
 *
 * The same Idempotency-Key + the SAME request body replays the original
 * 202 response verbatim (same commandId) — safe to retry a request you're
 * not sure landed, as long as you resend it unchanged. The same key with a
 * DIFFERENT body is a 409 `idempotency_key_reused`: the key identifies a
 * specific request, not just a slot, so the server refuses to guess which
 * body you meant.
 *
 * Self-contained: copy this whole file. No imports beyond the `fetch` you
 * already have.
 */

export type SampleContext = { baseUrl: string; apiKey: string; fetch: typeof fetch }

const DOCK_ID = 'sbx-run'
const IDEMPOTENCY_KEY = 'quickstart-05-idempotent-retry'

export async function run(ctx: SampleContext): Promise<string[]> {
  const lines: string[] = []

  const send = (command: 'open' | 'close') =>
    ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${ctx.apiKey}`,
        'Idempotency-Key': IDEMPOTENCY_KEY,
      },
      body: JSON.stringify({ command }),
    })

  const first = await send('open')
  const firstBody = (await first.json()) as { commandId: string; status: string }
  lines.push(`POST #1 (Idempotency-Key: ${IDEMPOTENCY_KEY}, command: open) -> ${first.status}`)
  lines.push(`commandId: ${firstBody.commandId}`)

  const replay = await send('open')
  const replayBody = (await replay.json()) as { commandId: string; status: string }
  lines.push(`POST #2 (same key, same body: open) -> ${replay.status}`)
  lines.push(`commandId matches POST #1: ${replayBody.commandId === firstBody.commandId ? 'yes' : 'no'}`)

  const mismatch = await send('close')
  const mismatchBody = (await mismatch.json()) as { type: string; status: number; title: string; code: string }
  lines.push(`POST #3 (same key, different body: close) -> ${mismatch.status}`)
  lines.push(`code: ${mismatchBody.code}`)

  return lines
}

if (require.main === module) {
  const ctx: SampleContext = {
    baseUrl: process.env.OUTPOST_BASE_URL ?? 'https://outpost.earthity.com/api/sandbox',
    apiKey: process.env.OUTPOST_API_KEY ?? 'spk_quickstart',
    fetch: globalThis.fetch,
  }
  run(ctx)
    .then(lines => {
      for (const line of lines) console.log(line)
    })
    .catch(err => {
      console.error(err)
      process.exit(1)
    })
}
```

**Expected output** (command ids will differ):

```text theme={null}
POST #1 (Idempotency-Key: quickstart-05-idempotent-retry, command: open) -> 202
commandId: cmd_<uuid>
POST #2 (same key, same body: open) -> 202
commandId matches POST #1: yes
POST #3 (same key, different body: close) -> 409
code: idempotency_key_reused
```

The same `Idempotency-Key` **and** the same request body replays the original `202` response verbatim — same `commandId`, no second actuation. That's what makes it safe to retry a request you're not sure landed: resend it unchanged. The same key with a **different** body is rejected with `409 idempotency_key_reused` — the key identifies one specific request, not a reusable slot, so the server refuses to guess which body you meant. A genuinely different command needs a new key.

## Error anatomy

```ts theme={null}
/*
 * Outpost quickstart sample 06 — error anatomy.
 *
 * Every error the API returns is an RFC 9457 problem+json body:
 *   - `type`     a URI identifying the error kind — resolves to a docs page
 *                under /errors/<code> (this IS the documentation link; some
 *                environments also carry a separate `documentation_url`
 *                member, reserved for the same purpose).
 *   - `status`   the HTTP status code, repeated in the body.
 *   - `title`    a short, stable, human-readable summary of the error kind.
 *   - `code`     the machine-readable error code — stable across releases,
 *                safe to switch on in client code.
 *   - `detail`   optional, request-specific detail (present on some codes,
 *                absent on others — never rely on it being there).
 *
 * This exercises two error paths: a bad/missing credential (401) and a
 * missing required header (400). Neither request creates a command, so it
 * doesn't matter which dock id the path names.
 *
 * Self-contained: copy this whole file. No imports beyond the `fetch` you
 * already have.
 */

export type SampleContext = { baseUrl: string; apiKey: string; fetch: typeof fetch }

const DOCK_ID = 'sbx-timeout-fault'

type Problem = { type: string; status: number; title: string; code: string; detail?: string }

export async function run(ctx: SampleContext): Promise<string[]> {
  const lines: string[] = []

  const unauthorizedRes = await ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: 'Bearer not-a-real-key',
      'Idempotency-Key': 'quickstart-06-error-anatomy-a',
    },
    body: JSON.stringify({ command: 'open' }),
  })
  const unauthorizedBody = (await unauthorizedRes.json()) as Problem
  lines.push(`POST /api/v1/docks/${DOCK_ID}/commands (Authorization: Bearer not-a-real-key) -> ${unauthorizedRes.status}`)
  lines.push(`type: ${unauthorizedBody.type}`)
  lines.push(`title: ${unauthorizedBody.title}`)
  lines.push(`code: ${unauthorizedBody.code}`)

  const missingKeyRes = await ctx.fetch(`${ctx.baseUrl}/api/v1/docks/${DOCK_ID}/commands`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${ctx.apiKey}`,
    },
    body: JSON.stringify({ command: 'open' }),
  })
  const missingKeyBody = (await missingKeyRes.json()) as Problem
  lines.push(`POST /api/v1/docks/${DOCK_ID}/commands (no Idempotency-Key header) -> ${missingKeyRes.status}`)
  lines.push(`type: ${missingKeyBody.type}`)
  lines.push(`title: ${missingKeyBody.title}`)
  lines.push(`code: ${missingKeyBody.code}`)
  lines.push(`detail: ${missingKeyBody.detail}`)

  return lines
}

if (require.main === module) {
  const ctx: SampleContext = {
    baseUrl: process.env.OUTPOST_BASE_URL ?? 'https://outpost.earthity.com/api/sandbox',
    apiKey: process.env.OUTPOST_API_KEY ?? 'spk_quickstart',
    fetch: globalThis.fetch,
  }
  run(ctx)
    .then(lines => {
      for (const line of lines) console.log(line)
    })
    .catch(err => {
      console.error(err)
      process.exit(1)
    })
}
```

**Expected output** (command ids will differ):

```text theme={null}
POST /api/v1/docks/sbx-timeout-fault/commands (Authorization: Bearer not-a-real-key) -> 401
type: https://docs.earthity.com/errors/unauthorized
title: Unauthorized
code: unauthorized
POST /api/v1/docks/sbx-timeout-fault/commands (no Idempotency-Key header) -> 400
type: https://docs.earthity.com/errors/validation_failed
title: Validation failed
code: validation_failed
detail: Idempotency-Key header is required.
```

Every error is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `application/problem+json` body:

* **`type`** — a URI identifying the error kind. It resolves to a docs page under `/errors/<code>` — this member *is* the documentation link.
* **`status`** — the HTTP status code, repeated in the body.
* **`title`** — a short, stable, human-readable summary of the error kind.
* **`code`** — the machine-readable error code. Stable across releases; safe to switch on in client code.
* **`detail`** — optional, request-specific detail. Present on some codes, absent on others — never rely on it being there.

The schema also reserves `instance`, `documentation_url`, and `request_id` members for production use; the sandbox doesn't populate them today, so treat `type` as your documentation link in the meantime.

## Magic dock id reference

Every dock id below is deterministic — pick the one that exercises the behavior you're testing.

| Dock id             | Behavior                                                                                                              |
| ------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `sbx-endstop`       | `202`, terminal `succeeded`/`AT_ENDSTOP` — the canonical happy path                                                   |
| `sbx-kick`          | `202`, terminal `succeeded`/`KICK`                                                                                    |
| `sbx-run`           | `202`, terminal `succeeded`/`RUN`                                                                                     |
| `sbx-obstructed`    | `202`, terminal `failed`/`OBSTRUCTED`                                                                                 |
| `sbx-timeout-fault` | `202`, terminal `failed`/`TIMEOUT_FAULT` — also available for testing                                                 |
| `sbx-busy`          | always `409 dock_busy`, regardless of `Idempotency-Key`                                                               |
| `sbx-slow`          | `202`, stays `executing` for a full 45s real-time window (the result-push exercise dock) — also available for testing |

Any other dock id returns `404 not_found`.
