# Space Context documentation

> Space Context is the context layer for the physical world: it turns a video of a physical space (a flat, a holiday rental, an office, a van, an appliance) into a structured, searchable Space Document: every area, every item and where it is, its condition, what is broken, where the water shut-off and the fuse box are, how things work, with the original video frames as evidence. Code and AI agents reach it through a REST API, a TypeScript SDK, a CLI and a remote MCP server.

Every page of https://spacecontext.app/developers/docs/ in reading order; the index with one line per page is https://spacecontext.app/developers/docs/llms.txt. Each page starts with its title and its URL.

---

# Space Context for developers

Source: https://spacecontext.app/developers/docs/
Markdown: https://spacecontext.app/developers/docs/intro.md

Space Context is the context layer for the physical world. Your agents know your code, your documents and your calendar; they know nothing about the places you live and work in. **Film a space once, and they know it too.**

It turns a video of a physical space (a flat, a holiday rental, an office, a van, an appliance) into a structured **Space Document**: every area, every item and where it is, its condition, what is broken, where the water shut-off and the fuse box are, how things work, with the original video frames as evidence. You read it, search it and ask it questions from your code, from the terminal or from an AI assistant.

## Four ways in

| | Best for | Start here |
| --- | --- | --- |
| **MCP server** | Giving Claude (or any MCP client) read access to your spaces, without writing code | [Connect an assistant](https://spacecontext.app/developers/docs/mcp/connect.md) |
| **CLI** | Scanning a video and exporting an Agent Skill from the terminal | [Install the CLI](https://spacecontext.app/developers/docs/cli/install.md) |
| **TypeScript SDK** | Building it into a Node.js, Deno or Cloudflare Workers app: one call from a video to the Space Document | [TypeScript SDK](https://spacecontext.app/developers/docs/sdk/typescript.md) |
| **REST API** | Building it into your product in any language: JSON over HTTPS, with an OpenAPI 3.1 contract | [API overview](https://spacecontext.app/developers/docs/api/overview.md) |

All four use the same account as the Space Context iPhone app, and the same [API keys](https://spacecontext.app/developers/docs/account/api-keys.md) from the [console](https://spacecontext.app/developers/docs/account/console-and-login.md).

## What you can do

- **Scan** a video (MP4, MOV, M4V, 3GP, WebM or Matroska, up to 4 GB and 10 minutes) or up to 120 photos. The analysis runs on our side; no ffmpeg needed (the CLI uses it, when installed, to compress a bigger video). → [Scans](https://spacecontext.app/developers/docs/api/scans.md)
- **Read** a space as JSON, by section. → [The Space Document](https://spacecontext.app/developers/docs/api/space-document.md)
- **Search** across all your spaces, in any language: "where did I see a ladder?" → [Search and ask](https://spacecontext.app/developers/docs/api/search-and-ask.md)
- **Ask** one space a question and get a short answer with its sources and photos, or "not documented". → [Search and ask](https://spacecontext.app/developers/docs/api/search-and-ask.md)
- **Compare** two scans of the same space: items added, removed, moved, issues new or resolved. → [Skills and comparisons](https://spacecontext.app/developers/docs/api/skills-and-diff.md)
- **Export** a space as an Agent Skill folder that any agent can read. → [Use a space in Claude Code](https://spacecontext.app/developers/docs/guides/claude-code-skill.md)
- **Get notified** when a scan completes or fails. → [Webhooks](https://spacecontext.app/developers/docs/api/webhooks.md)
- **Pay as an agent**: an agent with USDC on Base or Solana buys credit and a key in one call, with no account and no card. → [Agents that pay (x402)](https://spacecontext.app/developers/docs/account/agent-payments.md)

## Try it for free

A **test key** (`sc_test_…`) runs every flow for free on the demo space, two real scans of an apartment in Brisbane (photos at check-in, a narrated video at check-out): scans complete instantly, nothing is charged and none of your data is involved. Create one in the [console](https://spacecontext.app/developers/console/) and follow the [quickstart](https://spacecontext.app/developers/docs/quickstart.md).

The first **live** scan of a new account is free as well (one free scan per card). After that, live scans are paid from a prepaid balance. Live scans, and live keys that can scan, need a card on file: saved in the console without a charge, or a top-up already paid. See [Billing](https://spacecontext.app/developers/docs/account/billing.md).

## Use these docs from an agent

Every page of this documentation is also served as plain Markdown (the page's URL with `.md`), and the whole set comes in the formats agents read best:

- **`llms.txt`** — https://spacecontext.app/developers/docs/llms.txt: an index of every page with a one-line description. Give this URL to an agent; it fetches only the pages it needs.
- **`llms-full.txt`** — https://spacecontext.app/developers/docs/llms-full.txt: everything in one file, to paste into a conversation or add to a project's files.
- **The MCP server** — `https://mcp.spacecontext.app/mcp`: connect Claude or any MCP client to read your spaces themselves, not only the docs. → [Connect an assistant](https://spacecontext.app/developers/docs/mcp/connect.md)

For tooling: the [OpenAPI contract](https://spacecontext.app/developers/docs/openapi.json) (code generation, validation) and a [ZIP of every page](https://spacecontext.app/developers/docs/spacecontext-docs.zip) with the contract, for offline use.

---

# Quickstart: a searchable space in five minutes

Source: https://spacecontext.app/developers/docs/quickstart/
Markdown: https://spacecontext.app/developers/docs/quickstart.md

**Using a coding agent?** Paste the prompt in [Setup](https://spacecontext.app/developers/docs/setup.md) into Claude Code, Cursor or Codex, or run `npx spacecontext@latest setup` yourself: it signs you in, connects your assistants and installs the skill.

**No code at all?** Open the [console](https://spacecontext.app/developers/console/), sign in with Apple, Google, GitHub or your email, switch to **Test** and open the demo space: inventory, photos, questions and a comparison of two scans. Then add a card under **Billing → Add a card** (nothing is charged; live keys that can scan need one), switch to **Live**, open **New scan** and drop a video of a room: the first live scan of a new account is free (one free scan per card). **Connect AI** adds your spaces to Claude or ChatGPT in a minute.

![The Spaces page of the console in Test mode: the demo space, with its areas, items and issues, and a search box across every space](https://spacecontext.app/docs/console/spaces.jpg)

The rest of this page does the same from the terminal. It takes about five minutes and costs nothing: it uses a **test key**, which works on the demo space.

The demo is real output, not a mock-up: a one-bedroom apartment in Brisbane scanned twice through the API, with five photos at check-in and a narrated video at check-out, checked against the check-in. The narration (Wi-Fi, fuse box, bins, a loose heater cable) is invented for the example; the photos are by Chris Olszewski on Wikimedia Commons (CC BY-SA 4.0).

## 1. Sign in from the terminal

You need Node.js 22.12 or later.

```bash
npx spacecontext login --test
```

The terminal shows a code and opens the [console](https://spacecontext.app/developers/console/): sign in with Apple, Google, GitHub or your email (the same account as the iPhone app), type the code, approve. The terminal receives a **test** key: free, never charged, and it reads the demo space. → [Console and sign-in](https://spacecontext.app/developers/docs/account/console-and-login.md)

## 2. Use it

```bash
npx spacecontext spaces         # the demo space: spc_demo
npx spacecontext search "where is the fuse box?"
npx spacecontext ask spc_demo "where is the fuse box?"
```

`search` looks across every space you can read; `ask` answers from one space, with its sources.

## 3. Export it as an Agent Skill

```bash
npx spacecontext skill spc_demo --out ./brisbane-apartment-skill
```

The folder holds `SKILL.md`, `space.json` and one reference file per topic (areas, items, issues, key locations, how-to, facts). Point any agent that reads skills at it, for example `~/.claude/skills/` for Claude Code. → [Use a space in Claude Code](https://spacecontext.app/developers/docs/guides/claude-code-skill.md)

## 4. Run a scan

With a test key the whole scan flow runs on any video file (`.mp4`, `.mov`, `.m4v`, `.3gp`, `.webm` or `.mkv`, up to 4 GB and 10 minutes), for free, and the result is the demo space under the name you choose:

```bash
npx spacecontext scan ./tour.mp4 --name "Test flat"
```

![The demo space in the console: summary, issues with their evidence frames and severity, key locations, and the tabs Inventory, Ask, History & compare and Export](https://spacecontext.app/docs/console/space.jpg)

## 5. Go live

When you are ready for your own spaces:

1. Add a card in the console under **Billing → Add a card**: a live key that can scan needs one. Stripe saves it and nothing is charged.
2. Sign in again for a **live** key (or create one in the console).
3. Scan a real video: the first live scan of a new account is free (one free scan per card, see [Billing](https://spacecontext.app/developers/docs/account/billing.md#add-a-card)).

```bash
npx spacecontext login                      # approve a live key in the console
npx spacecontext scan ./flat.mp4 --name "Seaside flat" --type apartment
```

A scan takes a few minutes. Then `search`, `ask` and `skill` work on it, and so does the [MCP server](https://spacecontext.app/developers/docs/mcp/connect.md) once you connect your assistant.

## Next steps

- [Connect Claude to your spaces](https://spacecontext.app/developers/docs/mcp/connect.md)
- [Recording tips for good scans](https://spacecontext.app/developers/docs/cli/scanning.md)
- [Call the API from your code](https://spacecontext.app/developers/docs/api/overview.md)

---

# Set up Space Context

Source: https://spacecontext.app/developers/docs/setup/
Markdown: https://spacecontext.app/developers/docs/setup.md

One command, or the steps by hand, for the same result: your assistant connected to your spaces, the skill that tells it how to use them, and a key for the SDK, the CLI and the API.

## Set up with your agent

Paste this into **Claude Code**, **Cursor**, **Codex** or any coding agent:

```text
Set up Space Context for me. Run `npx spacecontext@latest setup --yes --json`, then follow its `next` steps; I will approve the sign-in in the browser.
```

The command prints a link and a code, then waits up to ten minutes while you open the link, type the code and approve (give the agent a long timeout for it). It then adds the MCP server to the Claude Code, Codex, Cursor and VS Code it finds, installs the skill and checks your card on file. Its JSON tells the agent what was done and what is left for you.

What the agent cannot do for you, on purpose: approving the sign-in, signing in to the MCP server inside each assistant, and adding a card. All three happen in your browser.

## Set up from a terminal

```bash
npx spacecontext@latest setup
```

The same command, run by you: it opens the browser for the sign-in, and for Codex it runs the MCP login in your terminal. Each step ends with ✓ done, – skipped or ✗ failed and one line on why; a step that fails never stops the others, and a failed one says what to run by hand.

| Step | What it does |
| --- | --- |
| Sign in | Uses the key the CLI already has (`--api-key`, `SPACECONTEXT_API_KEY` or the saved one) if the API accepts it; otherwise the device sign-in of [`login`](https://spacecontext.app/developers/docs/cli/install.md#sign-in). `--test` asks for a test key. The key is saved for the CLI and never printed. |
| Assistants | Claude Code (`claude mcp add`, user scope), Codex (`codex mcp add`), Cursor (adds the server to `~/.cursor/mcp.json`, keeping your other servers and a copy in `mcp.json.bak`) and VS Code (`code --add-mcp`). By default the ones it finds; `--client codex,cursor` or `--client all` to choose. A `spacecontext` server that already points elsewhere is kept (skipped), unless you pass `--yes`. |
| Skill | Saves the [skill](#3-install-the-skill) as `~/.claude/skills/spacecontext/SKILL.md`. A copy you have edited is kept unless you pass `--yes`. |
| Card | With a live key, reads the wallet: with no card on file (a saved card, or a top-up already paid), it opens **Billing** in the console (adding a card charges nothing); with a card but no funds and no free scan left, it says to add funds. It never asks for card details in the terminal. |
| Next | What is left to do, a few lines of SDK code and the docs. |

`--yes` runs without questions and without opening the browser (for agents); `--json` prints only a summary, `{ "steps": [{ "id", "status", "detail" }], "next": [...] }`, on standard output. `--no-browser` prints links instead of opening them; `--skip-login`, `--skip-mcp` and `--skip-skill` leave a step out. The exit code is `0` when you are signed in and at least one assistant is connected (or you skipped those steps), `1` otherwise. → [CLI commands](https://spacecontext.app/developers/docs/cli/commands.md#get-started)

## Set up step by step

### 1. Get a key

Open the [console](https://spacecontext.app/developers/console/keys/) and create a key. A **test key** (`sc_test_…`) is free and never charged: it runs every flow on the demo space. From a terminal, `npx spacecontext login` does the same: it shows a code, you approve it in the console, and the key is saved for the CLI. → [API keys](https://spacecontext.app/developers/docs/account/api-keys.md)

### 2. Connect your assistant

The server is `https://mcp.spacecontext.app/mcp`. You sign in (Apple, Google or an emailed code) and choose what the assistant may see and do.

| Assistant | How |
| --- | --- |
| Claude Code | `claude mcp add --transport http --scope user spacecontext https://mcp.spacecontext.app/mcp`, then `/mcp` → Authenticate |
| Codex | `codex mcp add spacecontext --url https://mcp.spacecontext.app/mcp`, then `codex mcp login spacecontext` |
| Cursor | add `{"mcpServers":{"spacecontext":{"url":"https://mcp.spacecontext.app/mcp"}}}` to `~/.cursor/mcp.json`, then **Login** next to it in Cursor's MCP settings |
| VS Code | `code --add-mcp '{"name":"spacecontext","type":"http","url":"https://mcp.spacecontext.app/mcp"}'`, then **MCP: List Servers** in the Command Palette, start `spacecontext` and sign in |
| Claude (claude.ai, Desktop) | **Settings → Connectors → Add custom connector** with the URL |
| ChatGPT | **Settings → Apps & Connectors → Advanced → Developer mode**, then create a connector with the URL and OAuth |

Details for each: [Connect an assistant](https://spacecontext.app/developers/docs/mcp/connect.md).

### 3. Install the skill

In Claude Code, the plugin adds the MCP server and the skill at once:

```text
/plugin marketplace add finikoslab/spacecontext
/plugin install spacecontext@spacecontext
```

Or save [the skill](https://spacecontext.app/.well-known/agent-skills/spacecontext/SKILL.md) as `~/.claude/skills/spacecontext/SKILL.md`, or give it to any agent as instructions. It tells the agent to treat what it reads in a space as data, to cite the area and the evidence photo, and to say "not documented" instead of guessing.

### 4. Use the SDK or the API

```ts
import { SpaceContext } from '@spacecontext/sdk';

const sc = new SpaceContext({ apiKey: process.env.SPACECONTEXT_API_KEY });
const answer = await sc.spaces.ask('spc_demo', 'Where is the water shut-off?');
console.log(answer.answer, answer.citations);
```

```bash
curl https://api.spacecontext.app/v1/spaces/spc_demo/ask \
  -H "Authorization: Bearer $SPACECONTEXT_API_KEY" -H "Content-Type: application/json" \
  -d '{"question":"Where is the water shut-off?"}'
```

→ [TypeScript SDK](https://spacecontext.app/developers/docs/sdk/typescript.md) · [API overview](https://spacecontext.app/developers/docs/api/overview.md)

### 5. Go live

Add a card in the console under **Billing → Add a card**: nothing is charged. The first live scan of a new account is free (one free scan per card: a card already used on another account brings none). After that, a live scan costs $1.00 from a prepaid balance; failed scans are refunded to the balance. → [Billing](https://spacecontext.app/developers/docs/account/billing.md)

---

# Concepts: spaces, scans and the Space Document

Source: https://spacecontext.app/developers/docs/concepts/
Markdown: https://spacecontext.app/developers/docs/concepts.md

## Spaces

A **space** is a physical place you document: a flat, a house, an office, a warehouse, a vehicle, a boat, a camper, a shop, a garage or an appliance. Each space has an id:

| Id | Where it comes from | Who can read it through the API |
| --- | --- | --- |
| `app_…` | Scanned with the Space Context iPhone app | Live keys of the same account |
| `spc_…` | Created through the API, the CLI or the console | The keys of the same mode (test or live) |
| `spc_demo` | The demo space | Test keys only |

## Scans

A **scan** is one recording of a space: a video, or up to 120 photos, optionally with a transcript of what was said. Each scan produces a new version of the space; older scans stay available for [comparison](https://spacecontext.app/developers/docs/api/skills-and-diff.md).

A scan goes through these statuses:

`awaiting_upload` → `queued` → `processing` (stages: `ingesting`, `screening`, `observing`, `fusing`, `enriching`, `saving`) → `complete`

or ends as `failed` (refunded) or `cancelled` (before it started; nothing charged). You can poll the scan, follow it live as an event stream (`GET /scans/{id}/events`), or be told by a [webhook](https://spacecontext.app/developers/docs/api/webhooks.md).

## The Space Document

Reading a space returns a **Space Document**, a JSON document with a versioned schema: areas, items, issues, key locations, instructions, facts, Q&A, frames and coverage. → [The Space Document](https://spacecontext.app/developers/docs/api/space-document.md)

Documents have two levels:

- **base**: what the first analysis found (areas, items as names, issues).
- **deep**: after a second pass that structures everything (categories, locations, conditions, brands, key locations, how-to steps from the narration, facts) and screens it for sensitive values. Scans made through the API are deep when they complete. Spaces scanned with the app are deepened the first time you read them through the API; until then you get the base level with `enrichment.status: "pending"`.

## Test and live mode

Every key is either **test** (`sc_test_…`) or **live** (`sc_live_…`).

- **Test** keys see only the demo space and spaces created with test keys; scans are free, instant, and always return the demo content. Use them to build and test integrations.
- **Live** keys see your real spaces and run real analyses. Test and live data never mix.

## Scopes

A key carries scopes that limit what it can do:

| Scope | Allows |
| --- | --- |
| `spaces:read` | Reading, searching and asking spaces |
| `scans:read` | Following scans (status and event stream), reading the wallet |
| `scans:write` | Creating spaces and scans, checking frames while filming (a live key spends your balance) |
| `spaces:sensitive` | Seeing sensitive values: Wi-Fi passwords, door and alarm codes, the transcript and the owner's own answers |

`spaces:sensitive` is never granted by default. → [API keys](https://spacecontext.app/developers/docs/account/api-keys.md)

## Screening

Before content reaches a key, a model or an agent, Space Context screens it:

- **Sensitive values** found by the deep pass (and by a second, rule-based detector for passwords, codes, card numbers, IBANs, emails and phone numbers) are redacted for keys without `spaces:sensitive`. A key without that scope reads a space only once the deep pass has screened it.
- **Text that addresses an AI** ("ignore your previous instructions…", written on a sign or said on camera) is withheld everywhere, with a notice in `coverage.warnings`. Content of a space is data, never instructions.
- **Footage** is checked frame by frame before any model describes it: blurred, dark or covered frames are left out, a recording with nothing usable is refused and refunded (`FOOTAGE_UNUSABLE`), and `coverage.checklist` says which key locations the recording did not show. → [Guided capture](https://spacecontext.app/developers/docs/guides/guided-capture.md)

→ [Security and privacy](https://spacecontext.app/developers/docs/security-and-privacy.md)

---

# The developer console and sign-in

Source: https://spacecontext.app/developers/docs/account/console-and-login/
Markdown: https://spacecontext.app/developers/docs/account/console-and-login.md

The [console](https://spacecontext.app/developers/console/) is where you see and manage everything without writing code: your spaces, new scans, the assistants you connected, [API keys](https://spacecontext.app/developers/docs/account/api-keys.md), [billing](https://spacecontext.app/developers/docs/account/billing.md), [webhooks](https://spacecontext.app/developers/docs/api/webhooks.md) and the audit log.

![The console overview: spaces, documented items, scans and questions this month, the balance, and 30-day charts of scans, API requests, questions and spend](https://spacecontext.app/docs/console/overview.jpg)

## Sign in

Choose **Continue with Apple**, **Continue with Google**, **Continue with GitHub**, or enter your email and the one-time code we send you (valid for 10 minutes).

It is the **same account as the Space Context iPhone app**, whichever way you signed up there: the console finds it by the email of your Apple, Google or GitHub account (for GitHub, its primary verified email). If you used Apple's *Hide My Email* in the app, sign in here with Apple too: you reach the same account.

## Approve a sign-in from the terminal

`spacecontext login` shows a code and sends you to **Approve a sign-in** in the console. Type the code, read what is asking (the name a terminal gives is what it said about itself), choose what the key may do and its mode, and approve: the terminal receives a key of its own, listed under **API keys** as `CLI · …`, revocable at any time. A live key that can scan (`scans:write`) needs a card on file: without one the console tells you so instead of approving. Add one under **Billing → Add a card** (nothing is charged) and approve again, or approve the key without `scans:write`. Approve only a code you are looking at in your own terminal; a code never travels in a link.

## What the console shows

| Section | What you can do |
| --- | --- |
| **Overview** | Spaces, documented items, scans and questions this month, balance; 30-day charts of scans, API requests, questions and spend; usage per key; the latest scans, updated live. A short **Get started** checklist takes a new account from the demo (free) to a card on file (nothing charged) to its first live scan (free, one free scan per card). |
| **Spaces** | Every space you can read, and a search across all of them. Open one for its summary, issues, key locations, how-tos and facts, the inventory by area with the **original photos**, questions answered with sources, the scan history with **before/after comparisons**, and exports (Agent Skill, JSON). |
| **New scan** | Drop a walkthrough video or photos, see the price (the account's free scan, if it has one, or $1.00), follow the analysis step by step, and land on the result. |
| **Connect AI** | The MCP server URL and the steps for Claude, Claude Code, ChatGPT and Cursor, with the assistants already connected. |
| **API keys** | Create test and live keys with scopes and a monthly spending cap (a live key that can scan needs a card on file); revoke them. Keys created by the MCP connector carry an **MCP** badge; keys approved for a terminal are named `CLI · …`. |
| **Billing** | Balance, the card on file (added without a charge), top-ups by card through Stripe, invoice details (individual or company, VAT number), automatic top-up, spend chart, every movement, and invoices. |
| **Webhooks** | HTTPS endpoints, events, test deliveries, delivery history. |
| **Security** | The audit log: sign-ins, keys, webhooks, payments and deletions, with who did them, kept 400 days. |

![API keys in the console: create a key with a mode, scopes and a monthly cap; your keys with their mode, scopes, last use and a Revoke button](https://spacecontext.app/docs/console/keys.jpg)

The **Live / Test** switch at the top changes what the spaces, scans and overview show: **Test** is the demo space and free, instant scans that return demo content; **Live** is your real spaces. Nothing in Test mode is ever charged.

## Sessions

- A console session lasts up to 30 days and lives only in the browser tab (not in cookies, not on disk). Closing the tab ends it.
- **Sign out** revokes the session on the server, and is recorded in the audit log.
- In the console you act as the owner of the account, with every permission. A console session cannot be used on the app's API, and an API key cannot open the console: keys cannot create keys, see billing or change webhooks.

## Limits on codes

To protect your account, emailed codes are rate-limited per address and per network. If you request many codes in a short time you are asked to wait a few minutes. A wrong code can be retried a few times before a new one is needed.

---

# API keys and scopes

Source: https://spacecontext.app/developers/docs/account/api-keys/
Markdown: https://spacecontext.app/developers/docs/account/api-keys.md

Every call to the API, the CLI and the SDK uses an **API key**, sent as a bearer token:

```http
Authorization: Bearer sc_live_…
```

Create keys in the [console](https://spacecontext.app/developers/console/) under **API keys**. The key is shown **once**: we store only its hash. If you lose it, revoke it and create another.

![Creating a key in the console: name, Test or Live mode, the four scopes, an optional monthly spending cap](https://spacecontext.app/docs/console/keys.jpg)

## Test and live

| | `sc_test_…` | `sc_live_…` |
| --- | --- | --- |
| Sees | The demo space `spc_demo` and spaces created with test keys | Your real spaces |
| Scans | Free, complete at once, always return the demo content | Real analysis; the account's free scan first (one per card), then paid from your balance |
| Models | None are called | Vision and language models |
| Webhooks | Sent to endpoints in test mode | Sent to endpoints in live mode |
| Card on file | Never needed | Needed for a key with `scans:write` |

Use test keys for development and CI; switch to a live key when you go to production. Test and live data never mix.

## Scopes

| Scope | Allows | Default |
| --- | --- | --- |
| `spaces:read` | `GET /spaces`, `GET /spaces/{id}`, search, ask, skills, comparisons | ✓ |
| `scans:read` | `GET /scans/{id}`, the event stream, `GET /wallet` | ✓ |
| `scans:write` | Creating spaces and scans, uploading, starting, cancelling, deleting spaces, frame checks | ✓ |
| `spaces:sensitive` | Wi-Fi passwords, door and alarm codes, the transcript, the owner's own answers, past scans of app spaces | — |

A key without `spaces:sensitive` reads the **redacted** document. For spaces scanned with the app, it reads them only once they have been screened; see [Security and privacy](https://spacecontext.app/developers/docs/security-and-privacy.md). A request that needs a missing scope answers `403 INSUFFICIENT_SCOPE`.

Give an agent the narrowest key that does the job: `spaces:read` alone for an assistant that only answers questions.

## Live keys that can scan need a card

A live key with `scans:write` (one of the default scopes) can be created only once your account has a **card on file**: add one in the console under **Billing → Add a card**. Stripe saves it and nothing is charged; scans are still paid from your balance, after the account's free scan if it has one (one free scan per card). See [Billing](https://spacecontext.app/developers/docs/account/billing.md#add-a-card).

Without a card, creating such a key answers `402 PAYMENT_METHOD_REQUIRED`, whether you create it in the console, approve it for `spacecontext login` or allow scanning when you connect an assistant. Test keys and live keys without `scans:write` (read-only) never need a card. Removing the card later does not revoke the keys you already have.

The same rule applies when a **live scan is created or starts**, and to a live frame check (`POST /frames/check`), whoever asks (a key, the console or an assistant): an account with no card saved and no top-up paid gets `402 PAYMENT_METHOD_REQUIRED` before any upload link is given, and nothing is charged, its free scan included. Test scans never need a card. A test scan's video is at most 95 MB: it is uploaded through the API and nothing of it is kept (its result is the demo).

## Monthly spending cap

A live key can have a **monthly cap** in US dollars. Scans that would take the key's spending for the calendar month over the cap are refused with `402 SPENDING_CAP_REACHED`; other keys are not affected. The cap counts what the key spends on scans, not your whole balance.

## Revoking

**Revoke** in the console takes effect at once: the next call with the key answers `401 INVALID_TOKEN`.

Keys created by the [MCP connector](https://spacecontext.app/developers/docs/mcp/connect.md) carry the name of the assistant and an **MCP** badge; they have `scans:write` only if you allowed scanning when you connected (which needs a card on file). Revoking one disconnects that assistant: it asks you to sign in again on its next request. Connecting the same assistant again replaces its previous key automatically. Keys created by `spacecontext login` are named `CLI · …` after the terminal that asked.

## Limits

Up to 20 active keys per account.

---

# Pricing and billing

Source: https://spacecontext.app/developers/docs/account/billing/
Markdown: https://spacecontext.app/developers/docs/account/billing.md

Reading, searching and asking spaces are included. **Live scans** are paid from a **prepaid balance in US dollars**.

| | Price |
| --- | --- |
| Test keys | Free, within the [limits](https://spacecontext.app/developers/docs/limits.md) |
| First live scan of a new account | Free, one free scan per card (see [Add a card](#add-a-card)) |
| Each live scan after that | **$1.00**, charged when the scan starts |
| Taxes | Prices exclude applicable taxes, added at checkout where they apply (see [Taxes](#taxes)) |
| Failed scans | Refunded automatically |
| Live scans, and live keys that can scan | Need a card on file: saved in the console without a charge, or a top-up already paid |

**Agents with a crypto wallet** can skip the card and the console: they pay in USDC on Base or Solana and get a key in the same call. See [Agents that pay (x402)](https://spacecontext.app/developers/docs/account/agent-payments.md).

Spaces you scanned with the iPhone app are readable through the API, the console and MCP with the same account, at no cost. Billing stays separate: the app's credits and Space Context Pro are App Store purchases; API scans are always paid from the balance, never from app credits.

## Add a card

A **live key that can scan** (`scans:write`), whether you create it in the console, approve it for `spacecontext login` or allow scanning when you connect an assistant, needs a **card on file**. [Test keys](https://spacecontext.app/developers/docs/account/api-keys.md#test-and-live) and read-only live keys never do. Without a card, creating such a key answers `402 PAYMENT_METHOD_REQUIRED`.

In the console, open **Billing → Add a card** (a new account also finds it in the **Get started** checklist of the Overview). As for a top-up, you first accept the billing terms (below). A **Stripe Checkout** page saves the card and **charges nothing**; your bank may ask you to confirm it with **3-D Secure**. The card counts once Stripe confirms it, within a few seconds of coming back to the console.

- The card is stored by Stripe, never by us. It is the same saved card that automatic top-up uses, but saving it does not turn automatic top-up on.
- **One free scan per card.** The first live scan of a new account is free, but a card brings a free scan only once: if the first card the account saves or pays a top-up with was already used on another Space Context account (even one since deleted), the account gets no free scan. Nothing is charged either way, and `free_scans_remaining` in the wallet says whether the free scan is there.
- Scans are paid from the balance: the card is charged only by a top-up you make or an automatic top-up you turn on.
- A new card replaces the previous one. **Remove saved card** deletes it from Stripe; keys you already created keep working.

## Add funds

In the console, tick the billing terms (below), then **Billing → Add $10 / $25 / $50 / $100**, or type another amount under **Other amount** and press **Add**. You are taken to a **Stripe Checkout** page. Pay by card, and you come back to the console. The balance updates within a few seconds, when Stripe confirms the payment.

![Billing in the console: the balance with its top-up buttons, the automatic top-up settings and the spend of the last 30 days](https://spacecontext.app/docs/console/billing.jpg)

- Amounts from $10 to $500 per top-up, in dollars and cents, in the console as in the API.
- Buying as a company? Add your [invoice details](#invoices-and-company-details) first, so the invoice has your company name and VAT number.
- The balance never expires and is used only for API scans.
- Tick **Save the card for automatic top-ups** to allow automatic top-up later; that card is also the card on file that live keys that can scan need. The card is stored by Stripe, never by us. Saving another card later replaces it, and the previous one is removed from Stripe.

## Taxes

Prices and top-up amounts are in US dollars and **exclude applicable taxes**. Where a tax such as VAT, GST or sales tax applies, Stripe's payment page adds it on top, from your billing address and, for a business, your tax ID, and the invoice shows it. Your balance always receives the amount you chose: $25 of credit is $25 on the balance.

- A business with a valid VAT number in another EU country is invoiced under the **reverse charge**, with no VAT added.
- Set your [invoice details](#invoices-and-company-details) before you pay. Automatic top-up needs them, since it is charged without you on the page.
- A refund to your card includes the tax on the refunded amount; your balance gives back the credit it bought.

## What you accept when you add funds

You accept these terms **once**, with a box in the console the first time you add credit or save a card. After that the console does not ask again: Stripe's payment page repeats them next to its pay button. When the terms change, the box is shown once more for the new version.

- **The credit is supplied at once.** It is added to your balance as soon as the payment succeeds, and the scans you start use it: you ask for it to be supplied now.
- **Credit already used cannot be refunded.** A scan that fails is refunded to your balance automatically (see below). Money goes back to a card only if you ask: write to support about unused credit. Statutory rights, where they apply, are not affected.
- **Unused credit never expires.**
- **Amounts exclude applicable taxes.** Where tax applies, it is added on top and shown on the invoice (see [Taxes](#taxes)); the balance receives the amount before tax.
- Payments ask your bank for **3-D Secure** when your card supports it, and show on your statement as **FINIKOS\* SPACECONTEXT**.
- The console records the time you accepted, the version of these terms and your IP address. That record, the ledger and your usage are what we show Stripe if a payment is disputed.

## Automatic top-up

With a saved card, the console can top up for you:

- **When the balance is below**: the threshold, in dollars.
- **Add**: the credit to add, from $10 to $500. Where tax applies, the card is charged this amount plus the tax, and the balance receives the amount.

A top-up is attempted right after a paid scan starts and leaves the balance below the threshold. It is also attempted when the balance cannot pay for the next scan, and when a scan is refused for insufficient funds; in that last case the next attempt to start can succeed. Two attempts are at least 10 minutes apart, with at most 3 per day.

If the bank declines the card, automatic top-up turns itself off, the console shows the reason, and the `wallet.auto_recharge_failed` [webhook](https://spacecontext.app/developers/docs/api/webhooks.md) is sent. Fix the card with a manual top-up and turn it back on. **Remove saved card** turns automatic top-up off and deletes the card from Stripe's customer record.

## When scans are charged and refunded

1. `POST /v1/scans/{id}/start` charges the scan in a single atomic step: the free scan first, then the balance. If neither covers it, the start answers `402 INSUFFICIENT_FUNDS` with `balance_cents` and `price_cents`, and nothing is charged.
2. A scan that fails (unreadable video, nothing recognisable, processing error, or more than 2 hours of processing) is refunded once, automatically, and its files are deleted.
3. A scan cancelled before it starts was never charged.

## The ledger

**Billing → Activity** lists every movement with the balance after it: top-ups, scans, refunds, free scans, card refunds and disputes. The balance is always the sum of the ledger, and it is checked every night.

If you ask your bank or Stripe for a refund of a top-up, the refunded amount is taken from the balance; if a payment is disputed, it is taken while the dispute is open. The balance can then go below zero until you add funds.

## Invoices and company details

Payments are processed by Stripe on behalf of Finikos S.r.l. (Italy). **Every top-up has an invoice**, automatic top-ups included, made out to the details on file when it is issued.

### Set your details

In the console, open **Billing → Invoice details**, choose **Individual** or **Company**, and save:

- **Name**, or the **company name**; the **billing email** that receives the invoices (your account's email to start with); the **billing address** and its country.
- **VAT number**, for a company. It is required for a company in the EU (a business in another EU country pays no VAT: reverse charge) and optional elsewhere. Type it with its country prefix, as in `IT12345678901` (in the EU and the UK the console adds the prefix if you leave it out). After you save, Stripe checks it with the tax authority, and the card shows **Verified**, **Pending check** or **Not verified**.
- **Italian customers.** A company gives its **Codice destinatario SDI** (7 characters, 6 for a public administration) or its **PEC** address for the electronic invoice: either is enough, and without either the invoice goes to its tax drawer (*cassetto fiscale*). An individual can add their **codice fiscale**.

Set them **before you pay**: invoices already issued do not change. If you have not set them in the console, Stripe's payment page lets you give them while paying: tick the box for purchasing as a business and enter the company name and VAT number there. Once your details are on file, the payment page no longer asks for them, and the name and address of the card you pay with never replace them: a company paying with a card in someone's name still gets its own invoice. The details are kept by Stripe with your customer record; the [privacy policy](https://spacecontext.app/privacy/) says how they are used.

### Download your invoices

**Billing → Invoices** lists every invoice with its date, number, amount and status, its page and its PDF. The API returns them at `GET /v1/billing/invoices`.

---

# Agents that pay (x402)

Source: https://spacecontext.app/developers/docs/account/agent-payments/
Markdown: https://spacecontext.app/developers/docs/account/agent-payments.md

An agent with a crypto wallet does not need a person to sign up, add a card or copy a key. It pays in **USDC on Base or on Solana** with [x402](https://x402.org), the HTTP payment protocol, and the same call returns **an API key and the credit**. The paying wallet is the account: no email, no password, no console.

| | |
| --- | --- |
| Endpoint | `POST https://api.spacecontext.app/v1/agent/credits` |
| Networks | Base (`eip155:8453`) and Solana (`solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`), USDC |
| Amount | `amount_cents` from 100 ($1) to 50 000 ($500); without a body, $1 |
| What it buys | Credit for live scans at **$1.00 each**; reading, search and answers are included. The credit does not expire |
| Network fee | Paid by the facilitator: the wallet needs only USDC, no ETH or SOL |
| Key | A live key with every scope (`spaces:read`, `scans:read`, `scans:write`, `spaces:sensitive`) |

To try the API first without paying, use a [test key](https://spacecontext.app/developers/docs/account/api-keys.md#test-and-live): it is free and runs everything on the demo space.

## Pay with an x402 client

Any x402 v2 client works. With the official one for TypeScript, a call with no body buys **$1, one scan**, and goes through as is: x402 clients refuse to pay more than $1 at once unless you raise their limit (`spendControls`), as below to buy five scans.

```bash
npm install @x402/fetch @x402/evm viem          # Base
npm install @x402/fetch @x402/svm @solana/kit   # Solana
```

**Base**, with the wallet's private key (`0x…`):

```ts
import { wrapFetchWithPaymentFromConfig } from '@x402/fetch';
import { ExactEvmScheme } from '@x402/evm';
import { privateKeyToAccount } from 'viem/accounts';

const pay = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [{ network: 'eip155:8453', client: new ExactEvmScheme(privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`)) }],
  spendControls: { maxAmountPerPayment: '$5' }, // the client's own cap: $1 unless raised
});

const res = await pay('https://api.spacecontext.app/v1/agent/credits', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ amount_cents: 500 }),
});
const credit = await res.json();
// credit.api_key: store it now, it is shown only once.
```

**Solana**, with the wallet's secret key in base58 (what Phantom or Solflare export):

```ts
import { wrapFetchWithPaymentFromConfig } from '@x402/fetch';
import { ExactSvmScheme, toClientSvmSigner } from '@x402/svm';
import { createKeyPairSignerFromBytes, getBase58Encoder } from '@solana/kit';

const signer = await createKeyPairSignerFromBytes(getBase58Encoder().encode(process.env.SOLANA_PRIVATE_KEY!));
const pay = wrapFetchWithPaymentFromConfig(fetch, {
  schemes: [{ network: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', client: new ExactSvmScheme(toClientSvmSigner(signer)) }],
  spendControls: { maxAmountPerPayment: '$5' },
});
// …then the same pay('https://api.spacecontext.app/v1/agent/credits', …) call as above.
```

The answer:

```json
{
  "object": "agent_credit",
  "network": "eip155:8453",
  "payer": "0x5e1f…c0de",
  "transaction": "0x8a3b…41f2",
  "livemode": true,
  "amount_cents": 500,
  "balance_cents": 500,
  "api_key": "sc_live_…",
  "api_key_prefix": "sc_live_ab12",
  "notice": "Store api_key now: it is shown only this once. …",
  "docs": "https://spacecontext.app/developers/docs/"
}
```

Then use the key like any other: `Authorization: Bearer sc_live_…` on the [API](https://spacecontext.app/developers/docs/api/overview.md), `SPACECONTEXT_API_KEY` for the [CLI](https://spacecontext.app/developers/docs/cli/install.md) and the [SDK](https://spacecontext.app/developers/docs/sdk/typescript.md). Start with [Quickstart](https://spacecontext.app/developers/docs/quickstart.md): create a space, upload a video, start the scan.

## What happens on the wire

1. `POST /v1/agent/credits` without payment answers **402** with a `PAYMENT-REQUIRED` header: base64 of a JSON whose `accepts` lists one option per network, Base first. Each names the amount in atomic USDC units (6 decimals: $1.00 is `1000000`), the USDC contract or mint, the address to pay (`payTo`) and, on Solana, `extra.feePayer`, the facilitator account that pays the network fee. The same JSON is in the body as `error.payment_required`, so you can read it with `curl`:

   ```bash
   curl -s -X POST https://api.spacecontext.app/v1/agent/credits | jq .error.payment_required.accepts
   ```

2. The client picks one option and signs it: on Base an EIP-3009 `transferWithAuthorization`; on Solana a transaction that moves exactly that amount of USDC into the `payTo` wallet's USDC account, which the client signs and the facilitator co-signs as fee payer.
3. It sends **the same request with the same body** again, adding `PAYMENT-SIGNATURE`. The amount follows the body: a payment signed for $5 does not buy $10.
4. We check the payment against what we asked (network, asset, recipient, amount, signature) before anything is sent on; the facilitator verifies and settles it; the answer carries the key, the balance and a `PAYMENT-RESPONSE` header with the transaction.

The 402 also carries `extensions.bazaar`, which describes the call for x402 catalogs: agents that browse a catalog can find this endpoint without these docs.

## The account and its key

- **One wallet, one account.** Every payment from the same wallet tops up the same account. A Base wallet and a Solana wallet are two accounts. Agent accounts have no free scan.
- **The key is shown once**, in the answer of the payment that opens the account. Later payments from the wallet return only `api_key_prefix`: a signed payment can be submitted by whoever holds it, so a payment alone never hands out a key for an account that already exists.
- **Replace the key** by paying again with `{"rotate_key": true}` and `Authorization: Bearer <the current key>`: a new key comes back and the old one is revoked. Lost the key? Write to support with the paying address and one of its transactions.
- **The same payment twice** (a retry, a relay) is recognised by its transaction: nothing is credited or created twice.
- Failed scans are refunded to the balance, as for every account. Paying accepts the [Terms](https://spacecontext.app/terms/) (section 30 covers payments by agents).

## Errors

All answers use the usual [error format](https://spacecontext.app/developers/docs/api/errors.md); `reason` says why a payment was refused. Nothing is charged in any of these cases, except where noted.

| Status | Code | When |
| --- | --- | --- |
| 400 | `PAYMENT_INVALID` | `PAYMENT-SIGNATURE` is malformed or does not match what was asked: another amount, network, asset or recipient, a Solana transaction for another fee payer, a signature that does not verify |
| 402 | `PAYMENT_REQUIRED` | No payment yet: the options are in `PAYMENT-REQUIRED` |
| 402 | `PAYMENT_FAILED` | The facilitator refused or could not settle it, e.g. not enough USDC in the wallet (`invalid_exact_evm_insufficient_balance` on Base, `invalid_exact_svm_transaction_simulation_failed` on Solana) |
| 403 | `AGENT_KEY_REQUIRED` | `rotate_key`, or a key for an account that has none, without one of the account's current keys |
| 429 | `RATE_LIMIT_EXCEEDED` | More than 5 requests a minute from one IP address |
| 500 | `INTERNAL_ERROR` | The payment settled but could not be recorded: it is reconciled by hand; keep `transaction` |
| 502 | `PAYMENT_FAILED` | The settlement could not be confirmed: it may have gone through; write to support with `nonce` (Base) or `signature` (Solana) |
| 503 | `PAYMENTS_UNAVAILABLE` | The chosen network cannot be served right now; try again later or pay on the other one |

---

# API overview

Source: https://spacecontext.app/developers/docs/api/overview/
Markdown: https://spacecontext.app/developers/docs/api/overview.md

```text
https://api.spacecontext.app/v1
```

A JSON API over HTTPS. Field names are `snake_case`, timestamps ISO 8601 in UTC, money in US cents. The contract is the [OpenAPI document](https://api.spacecontext.app/v1/openapi.json), browsable in the [API reference](https://spacecontext.app/developers/docs/api/reference.md); it lists exactly what you call with a key (the console's own endpoints are not in it). `GET /v1` answers a short descriptor with the links to the contract, the docs, `llms.txt` and the MCP server.

## Authentication

Send an [API key](https://spacecontext.app/developers/docs/account/api-keys.md) as a bearer token:

```bash
curl https://api.spacecontext.app/v1/me \
  -H "Authorization: Bearer $SPACECONTEXT_API_KEY"
```

```json
{
  "user": { "id": "…", "email": "you@example.com", "is_pro": false },
  "key": { "id": "key_…", "mode": "test", "scopes": ["spaces:read", "scans:read", "scans:write"] }
}
```

The console endpoints (`/keys`, `/billing`, `/webhook-endpoints`, and `/auth/*` except the sign-in steps themselves) take a console session instead and are meant for the [console](https://spacecontext.app/developers/docs/account/console-and-login.md). The terminal sign-in, `POST /auth/device` and `POST /auth/device/token`, takes no credential at all: it is how a tool without a key gets one (what `spacecontext login` does).

## Errors

Every error has the same envelope, with a stable `code` to branch on and a `request_id` to quote to support. Some errors add fields:

```json
{
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "The wallet does not cover this scan",
    "request_id": "8f0c…",
    "balance_cents": 60,
    "price_cents": 100
  }
}
```

See [all error codes](https://spacecontext.app/developers/docs/api/errors.md).

## Pagination

Lists that can grow take `limit` and `cursor` and answer `next_cursor` (`null` on the last page):

```bash
curl "https://api.spacecontext.app/v1/spaces?limit=20&cursor=$NEXT" -H "Authorization: Bearer $KEY"
```

## Idempotency

`POST /spaces/{id}/scans` accepts an `Idempotency-Key` header (8 to 64 letters, digits, `-` or `_`). Retrying with the same key returns the scan already created (`200`) instead of creating another (`201`). `POST /scans/{id}/start` is safe to repeat: a second call answers `200` with the scan already started and charges nothing.

## Rate limits

| Limit | Value |
| --- | --- |
| Per key | 120 requests per minute |
| Per IP address | 1 200 requests per minute |
| Search, per account | 20 per minute, 1 000 per day |
| Questions (`ask`), per account | 20 per minute, 200 answered per day |
| Frame checks, per account | 2 000 per day |
| Scans processing at once, per account | 10 (more on request) |
| New spaces, per account | 200 per day in each mode (test keys included) |
| New scans, per account | 500 per day in each mode (test keys included) |
| Event streams open at once, per account | 10, test and live together |

A refused request answers `429 RATE_LIMIT_EXCEEDED` with the seconds to wait in `retry_after`, and in a `Retry-After` header for per-minute limits, new spaces and scans, and event streams. Daily limits reset at midnight UTC; new spaces and scans also say their allowance in `daily_limit`. An eleventh event stream answers `Retry-After: 30` with `max_streams`: poll `GET /scans/{id}` until one closes. The SDK and the CLI wait and retry short waits for you, and follow a scan by polling when no stream can be opened. See [Limits](https://spacecontext.app/developers/docs/limits.md).

## Test mode

Requests made with a test key (`sc_test_…`) never touch real data: see [Concepts](https://spacecontext.app/developers/docs/concepts.md#test-and-live-mode). Responses have the same shape in both modes; scans and spaces carry `livemode`.

## Browsers

The API is meant to be called from servers, scripts and agents. Browser calls are accepted only from Space Context's own sites (CORS); keep keys out of web pages.

## Endpoints at a glance

| | |
| --- | --- |
| `GET /me` | The key in use |
| `GET /spaces`, `POST /spaces` | List spaces, create a space |
| `GET /spaces/{id}`, `DELETE /spaces/{id}` | Read a space (Space Document), delete an API space |
| `GET /spaces/{id}/scans`, `POST /spaces/{id}/scans` | Scan history, create a scan |
| `GET /spaces/{id}/scans/{scan_id}` | A past scan as recorded |
| `GET /spaces/{id}/skill`, `GET /spaces/{id}/markdown` | The space as an Agent Skill, or as one Markdown page |
| `POST /spaces/{id}/ask` | Ask a question |
| `GET /spaces/{id}/diff` | Compare two scans |
| `GET /search` | Search across spaces |
| `GET /scans/{id}`, `DELETE /scans/{id}` | A scan, cancel a scan |
| `GET /scans/{id}/events` | Follow a scan live (Server-Sent Events) |
| `PUT /scans/{id}/frames/{index}`, `PUT /scans/{id}/video` | Upload |
| `POST /scans/{id}/start` | Start and charge |
| `POST /frames/check` | Is this frame usable, which key locations does it show (`?space_type=` for that type's, and the area) |
| `GET /capture-profiles`, `GET /capture-profiles/{space_type}` | What to film for each type of space (public, no key) |
| `GET /wallet` | Balance and free scans |
| `POST /auth/device`, `POST /auth/device/token` | Sign in from a terminal (what `spacecontext login` does) |
| `GET /media/{key}` | An evidence frame (signed URL) |

---

# Scans API

Source: https://spacecontext.app/developers/docs/api/scans/
Markdown: https://spacecontext.app/developers/docs/api/scans.md

A scan goes through four calls: **create**, **upload**, **start**, then **follow** it (an event stream, polling, or [webhooks](https://spacecontext.app/developers/docs/api/webhooks.md)). The [SDK](https://spacecontext.app/developers/docs/sdk/typescript.md) does all of it in one call, `sc.scan()`.

```bash
API=https://api.spacecontext.app/v1
AUTH="Authorization: Bearer $SPACECONTEXT_API_KEY"
```

## 1. Create a space

```bash
curl -X POST $API/spaces -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"name": "Seaside flat", "space_type": "apartment"}'
```

```json
{ "object": "space", "id": "spc_3f…", "source": "api", "livemode": true, "name": "Seaside flat", "space_type": "apartment", "description": null, "external_id": null, "created_at": "…", "updated_at": "…" }
```

`external_id` (up to 128 characters) lets you store your own reference, for example a listing id.

An account creates at most 200 spaces and 500 scans a day in each mode, test keys included; past that, `429 RATE_LIMIT_EXCEEDED` with `Retry-After` until midnight UTC and the allowance in `daily_limit`. A retry with the same `external_id` or `Idempotency-Key` returns what was already created and does not count again.

## 2. Declare the scan

For a video, declare its type and exact size: up to 4 GB on api.spacecontext.app, and `limits.max_video_bytes` of `GET /v1` states the limit of the deployment you call ([below](#3-upload)):

```bash
curl -X POST $API/spaces/spc_3f…/scans -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: flat-2026-10-05" \
  -d '{"input": {"kind": "video", "language": "en", "video": {"content_type": "video/mp4", "content_length": 48211983}}}'
```

`content_type` is the file's container:

| `content_type` | Container | Read inside it |
| --- | --- | --- |
| `video/mp4`, `video/quicktime`, `video/x-m4v`, `video/3gpp` | MP4, MOV, M4V, 3GP | H.264, HEVC (8 and 10 bit), AV1, ProRes, MPEG-4 part 2, H.263; AAC, Opus, PCM, AMR or no audio; fragmented MP4 too |
| `video/webm`, `video/x-matroska`, `video/matroska` | WebM, Matroska (`.mkv`) | VP9 or H.264; Opus or AAC |

Any frame rate is fine, and a phone's rotation is applied: a portrait video gives upright frames. Any other type (AVI, MPEG-TS, Ogg, FLV, WMV…) is refused here with `400 INVALID_CONTENT_TYPE`, before anything is created or charged; convert it to MP4 first: `ffmpeg -i in.avi -c:v libx264 -c:a aac -movflags +faststart out.mp4`.

The duration is read from the container itself, whatever type you declared (for a fragmented MP4 that does not state it, from the video), and a video over 10 minutes fails with `VIDEO_TOO_LONG`, refunded. A WebM recorded in a browser with `MediaRecorder` states no duration: where the API reads videos with its own ffmpeg (`limits.max_video_bytes` of `GET /v1` above 100 MB), its length is measured and it is read like any other video; elsewhere its scan fails with `VIDEO_UNREADABLE`, refunded. There, remux it first, which writes the duration: `ffmpeg -i in.webm -c copy out.webm`.

For photos, list them in order, with their time in milliseconds when you know it:

```json
{ "input": { "kind": "frames", "language": "it", "frames": [{ "timestamp_ms": 0 }, { "timestamp_ms": 2000 }] } }
```

`language` is the language the report is written in, `en` by default:

| Code | Language |
| --- | --- |
| `en` | English |
| `it` | Italiano |
| `es` | Español |
| `fr` | Français |
| `de` | Deutsch |
| `pt` | Português |
| `nl` | Nederlands |

The narration can be in any language: a video narrated in Spanish can have its report in English.

Either can carry a `transcript` (`{ "text": "…", "segments": [{ "start_ms": 0, "end_ms": 1500, "text": "This is the kitchen." }] }`) when you already have one; for videos without it, the audio is transcribed for you.

The answer is the scan, with upload instructions:

```json
{
  "object": "scan", "id": "scn_8a…", "status": "awaiting_upload",
  "upload": {
    "kind": "video", "method": "PUT",
    "url": "https://…",
    "headers": { "Content-Type": "video/mp4", "Content-Length": "48211983" },
    "expires_at": "…"
  }
}
```

## 3. Upload

**Video**: `PUT` the bytes to `upload.url` with `upload.headers`. A video can be up to 4 GB and 10 minutes. When the URL points to storage (a signed link, valid for an hour), do not send your API key; only this link takes a video over 95 MB. When it points to the API (`…/v1/scans/{id}/video`, up to 95 MB, in `upload.max_bytes`), send the `Authorization` header as usual.

```bash
curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --upload-file flat.mp4
```

`--upload-file` sends the file from disk as it goes, with its length; `--data-binary @flat.mp4` would read all of it into memory first.

`GET /v1` states the limits of the deployment it answers for in `limits` (`max_video_bytes`, `max_video_seconds`, `max_photos`). A `content_length` over `max_video_bytes` is refused when the scan is declared (`400 VALIDATION_ERROR`, with the limit in the message), before anything is uploaded or charged. A deployment that does not state its limits takes videos up to 100 MB.

The `Content-Type` of the upload must be the declared type: another one is refused with `415 INVALID_CONTENT_TYPE`. The `Content-Length` must be the declared size: through the API another one is refused with `400 VALIDATION_ERROR`, and the storage link, which signs the length, refuses it too.

**Size.** A phone's default recording fits: ten minutes at 1080p (16–17 Mbps) come to about 1.2 GB, and in 4K at 30 fps to about 3 GB. On a slow connection, compress before uploading (a scan needs no more than about 8 Mbps); the [CLI](https://spacecontext.app/developers/docs/cli/scanning.md#automatic-compression) does it with `--compress` when ffmpeg is installed, and by itself for a video over the limit.

**Photos**: one `PUT` per photo to `upload.url_template` with `{index}` replaced, starting at 0:

```bash
curl -X PUT $API/scans/scn_8a…/frames/0 -H "$AUTH" -H "Content-Type: image/jpeg" --data-binary @frame-0.jpg
```

Uploads can be repeated. A scan not started within 24 hours is cancelled and its uploads deleted.

## 4. Start

```bash
curl -X POST $API/scans/scn_8a…/start -H "$AUTH" -H "Content-Type: application/json" -d '{}'
```

`202` with the scan `queued` and its `charge`: `{ "kind": "free_scan" | "scan_charge", "price_cents": 100, "refunded": false }`. The scan is charged now, atomically; see [Billing](https://spacecontext.app/developers/docs/account/billing.md).

| Answer | Meaning |
| --- | --- |
| `202` | Started |
| `200` | Already started; nothing charged again |
| `409 UPLOAD_INCOMPLETE` | Some photos or the video are missing |
| `429 SCANS_IN_PROGRESS` | Ten scans of the account are already processing; start again when one finishes |
| `402 INSUFFICIENT_FUNDS` | Add funds (automatic top-up, if enabled, starts now) |
| `402 SPENDING_CAP_REACHED` | The key's monthly cap is reached |

## 5. Follow

```bash
curl -N $API/scans/scn_8a…/events -H "$AUTH"      # live, as Server-Sent Events
curl $API/scans/scn_8a… -H "$AUTH"                 # or one look
```

`status` moves through `queued`, `processing` (with `stage`: `ingesting`, `screening`, `observing`, `fusing`, `enriching`, `saving`, `verifying`) to `complete`, or `failed` with `error` and `charge.refunded: true`.

`GET /scans/{id}/events` is a `text/event-stream`: it sends the scan at once as an event named `scan` (the same JSON as `GET /scans/{id}`, without `upload`), then again at every change of status, stage or error, and closes after the final state. A `ping` event every 15 seconds keeps the connection alive; one stream lasts at most 30 minutes, after which you reconnect. Nothing to poll, nothing to miss:

```text
event: scan
id: 1
data: {"object":"scan","id":"scn_8a…","status":"queued",…}

event: scan
id: 2
data: {"object":"scan","id":"scn_8a…","status":"processing","stage":"observing",…}
```

An account holds at most 10 streams open at once, test and live together: the next one answers `429 RATE_LIMIT_EXCEEDED` with `Retry-After: 30` and `max_streams`. Close one, or poll meanwhile; the SDK, the CLI and the MCP server poll for you.

Polling `GET /scans/{id}` every few seconds works too, and so do the `scan.completed` and `scan.failed` [webhooks](https://spacecontext.app/developers/docs/api/webhooks.md) for servers that must not hold a connection.

**Screening.** Before the analysis, every frame is checked once against the [capture profile](https://spacecontext.app/developers/docs/guides/guided-capture.md#what-to-film-for-each-type-of-space) of the space's type: is it usable, does it show the space (a room, a vehicle, a boat, an appliance), and which of that type's key locations does it show. Blurred, dark or covered frames are left out, and the sharpest frame of each part of the video is the one analysed. Footage with no usable frame at all fails with `FOOTAGE_UNUSABLE` and is refunded. What the frames showed becomes `coverage.checklist` in the document. To check frames while filming, before a scan, see [Guided capture](https://spacecontext.app/developers/docs/guides/guided-capture.md).

**Verification.** After the analysis, every item, issue and key location is checked again on the frames it cites by a second model that answers closed questions and never writes text (`stage: verifying`, a few seconds): see [Verified evidence](https://spacecontext.app/developers/docs/guides/verified-evidence.md).

Then read the space: `GET /spaces/spc_3f…` returns its [Space Document](https://spacecontext.app/developers/docs/api/space-document.md).

## Cancel

`DELETE /scans/{id}` cancels a scan still `awaiting_upload` (`204`). Nothing is charged and its uploads are deleted.

## With the SDK

```ts
import { SpaceContext } from '@spacecontext/sdk';

const sc = new SpaceContext();
const { spaceId, document } = await sc.scan('./flat.mp4', {
  name: 'Seaside flat',
  type: 'apartment',
  onProgress: (p) => console.log(p.type === 'scan' ? p.label : p.type),
});
```

One call creates the space, uploads, starts, follows the event stream and returns the document (`format: 'markdown'` or `'skill'` adds the export). The steps are also available one by one: `spaces.create`, `scans.fromVideo` / `scans.fromFrames`, `scans.events` / `scans.wait`.

---

# Search spaces and ask questions

Source: https://spacecontext.app/developers/docs/api/search-and-ask/
Markdown: https://spacecontext.app/developers/docs/api/search-and-ask.md

## Search across spaces

```bash
curl "https://api.spacecontext.app/v1/search?q=where+did+I+see+a+ladder&limit=5" \
  -H "Authorization: Bearer $SPACECONTEXT_API_KEY"
```

```json
{
  "object": "search_results",
  "query": "where did I see a ladder",
  "data": [
    {
      "space_id": "spc_3f…", "space_name": "Seaside flat", "scan_id": "scn_8a…",
      "record_type": "item", "record_id": "itm_12",
      "title": "Aluminium step ladder", "area": "Storage room",
      "text": "Aluminium step ladder, folded against the wall behind the door…",
      "score": 0.87
    }
  ]
}
```

| Parameter | |
| --- | --- |
| `q` | The question, 1–300 characters, in any language. |
| `space_id` | Optional. Only this space. |
| `limit` | 1–25, default 10. |

Records are items, areas, issues, key locations, instructions, facts and Q&A. Search is semantic (multilingual embeddings) with a reranker: "rubinetto dell'acqua" finds "water shut-off valve". Each result points to a record you can open with `GET /spaces/{space_id}`.

- A space appears once its current scan has been indexed: right after a scan completes, or, for spaces scanned with the app, after their deep pass.
- The index holds only redacted content: results never contain a sensitive value, whatever the key's scopes.
- Test keys search the demo space, without a model.

## Ask one space

```bash
curl -X POST https://api.spacecontext.app/v1/spaces/spc_3f…/ask \
  -H "Authorization: Bearer $SPACECONTEXT_API_KEY" -H "Content-Type: application/json" \
  -d '{"question": "Where is the water shut-off?"}'
```

```json
{
  "object": "answer",
  "space_id": "spc_3f…", "scan_id": "scn_8a…",
  "question": "Where is the water shut-off?",
  "answer": "Under the bathroom sink, behind the cupboard door.",
  "documented": true,
  "citations": [
    {
      "record_type": "key_location", "record_id": "loc_1",
      "title": "Main water valve", "area": "Bathroom",
      "evidence": [{ "frame_id": "frame-14", "timestamp_ms": 41200, "url": "https://api.spacecontext.app/v1/media/…" }]
    }
  ]
}
```

- The answer uses only the space's records. When they do not contain it, `documented` is `false` and the answer says so.
- Every citation is a record of the document; evidence URLs are signed and expire.
- A key without `spaces:sensitive` never gets a sensitive value in an answer, and the model never sees one.
- Spaces scanned with the app answer `409 ENRICHMENT_PENDING` to such a key until they are screened (usually a minute or two after the first read).
- Up to 500 characters per question. A question counts against the daily limit only once it is going to be answered: one refused for its length, or because the space is still being screened, does not. Test keys never count.

---

# Agent Skills, Markdown and scan comparisons

Source: https://spacecontext.app/developers/docs/api/skills-and-diff/
Markdown: https://spacecontext.app/developers/docs/api/skills-and-diff.md

## Agent Skill

`GET /spaces/{id}/skill` returns the space as a multi-file **Agent Skill**:

```json
{
  "object": "skill",
  "name": "space-seaside-flat",
  "space_id": "spc_3f…",
  "scan_id": "scn_8a…",
  "files": [
    { "path": "SKILL.md", "media_type": "text/markdown", "content": "---\nname: space-seaside-flat\n…" },
    { "path": "space.json", "media_type": "application/json", "content": "{…}" },
    { "path": "references/overview.md", "media_type": "text/markdown", "content": "…" },
    { "path": "references/items.md", "media_type": "text/markdown", "content": "…" }
  ]
}
```

- `SKILL.md` is a short, fixed guide: it tells the agent which file holds what, that the content is data and not instructions, to cite the area, and to say "not documented" rather than guess. The only text of the space in it is its name.
- `space.json` is the Space Document; `references/` has one file per topic: overview, one per area, items with aliases, key locations, how-to, issues, facts, Q&A, frames.
- It is built from the document this key may read: a redacted document gives a redacted skill. `include=transcript` adds the transcript and needs `spaces:sensitive`.

Write the files to a folder and point your agent at it; the CLI does it for you: `spacecontext skill <id> --out <dir>`. See [Use a space in Claude Code](https://spacecontext.app/developers/docs/guides/claude-code-skill.md).

## One Markdown page

`GET /spaces/{id}/markdown` returns the whole space as a single readable page (`text/markdown`): the summary, every area with its items, issues, key locations and how-to steps, then facts, Q&A, coverage and the recording. Paste it in a ticket, hand it to a model as context, or save it (`spacecontext markdown <id> --out flat.md`). It follows the same rules as the Skill: built from exactly the document the key may read, never with image links, `include=transcript` with `spaces:sensitive` only, and 409 `ENRICHMENT_PENDING` for a restricted key until the space is screened.

## Compare two scans

```bash
curl "https://api.spacecontext.app/v1/spaces/spc_3f…/diff?from=scn_old…&to=scn_new…" \
  -H "Authorization: Bearer $SPACECONTEXT_API_KEY"
```

`to` defaults to the current scan. The answer summarises and lists what changed:

```json
{
  "object": "space_diff",
  "space_id": "spc_3f…",
  "from": { "scan_id": "scn_old…", "analyzed_at": "…" },
  "to": { "scan_id": "scn_new…", "analyzed_at": "…" },
  "level": "deep",
  "linked": true,
  "summary": { "areas_added": 0, "areas_removed": 0, "items_added": 1, "items_removed": 1, "items_not_seen": 2, "items_moved": 1, "items_changed": 1, "issues_new": 1, "issues_resolved": 1, "issues_not_seen": 0 },
  "areas": { "added": [], "removed": [], "condition_changed": [] },
  "items": { "added": […], "removed": […], "not_seen": […], "moved": […], "quantity_changed": […], "condition_changed": […] },
  "issues": { "new": […], "resolved": […], "not_seen": […] }
}
```

### Gone, or just not filmed?

A video shows what the camera pointed at, and two analyses of the same space never list exactly the same objects. So a comparison keeps two lists apart:

| | Means |
| --- | --- |
| `items.removed`, `issues.resolved` | **Confirmed.** A frame of the later scan shows the place where it was, without it. Each entry has a `note` and that frame. |
| `items.not_seen`, `issues.not_seen` | In the earlier scan, not in the later one, not confirmed: it may be gone, or its place was not filmed. Check it before acting on it. |

Confirmation comes from the later scan itself. When you scan a space created through the API again, the analysis is given the previous scan's inventory as a checklist: it links what it sees again (`previous_item_id`, `previous_issue_id` in the [document](https://spacecontext.app/developers/docs/api/space-document.md)) and reports what is gone only with a frame of the empty place (`continuity`). The comparison of two consecutive scans uses those links (`"linked": true`).

Otherwise (two scans that are not consecutive, or spaces scanned with the app) records are matched by content: area names, then the generic name of each item (`canonical_name`) in the same area, then items worded differently (*sideboard* and *TV cabinet*, *fan heater* and *heater*), never across categories. Nothing can then be confirmed gone, so what is missing is `not_seen`. Comparisons are always between two documents of the same level. List the scans with `GET /spaces/{id}/scans`.

### Evidence

Every entry carries `evidence.from` and `evidence.to`: the frames of each scan that show it, each with its signed `url` (`null` when your key may not see that image). For something gone, `to` is the photo of the empty place.

For spaces scanned with the app, past scans are not screened for sensitive values, so comparisons need `spaces:sensitive`. See the [check-in / check-out guide](https://spacecontext.app/developers/docs/guides/check-in-check-out.md).

---

# Webhook events and signatures

Source: https://spacecontext.app/developers/docs/api/webhooks/
Markdown: https://spacecontext.app/developers/docs/api/webhooks.md

Instead of polling, let Space Context call you. Add an endpoint in the [console](https://spacecontext.app/developers/console/) under **Webhooks**: an HTTPS URL, the events, and the mode (test events come from test keys, live events from live keys). The **signing secret** (`whsec_…`) is shown once.

## Events

| Event | When | `data` |
| --- | --- | --- |
| `scan.completed` | A scan finished | `{ "scan": Scan }` |
| `scan.failed` | A scan failed and was refunded | `{ "scan": Scan }` with `error` and `charge.refunded: true` |
| `wallet.auto_recharge_failed` | The bank declined an automatic top-up; it was turned off | `{ "reason", "amount_cents", "auto_recharge_enabled": false }` |

## Request

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: SpaceContext-Webhooks/1
SpaceContext-Event-Id: evt_scn_8a…_completed
SpaceContext-Signature: t=1791200000,v1=5f2c…

{
  "id": "evt_scn_8a…_completed",
  "object": "event",
  "type": "scan.completed",
  "livemode": true,
  "created_at": "2026-10-05T10:15:03.000Z",
  "data": { "scan": { "object": "scan", "id": "scn_8a…", "space_id": "spc_3f…", "status": "complete", … } }
}
```

Answer with any `2xx` within 10 seconds. Do the work after answering.

## Verify the signature

`SpaceContext-Signature` is `t=<unix seconds>,v1=<hex HMAC-SHA256>` of `"<t>.<raw body>"`, keyed with the whole signing secret (including `whsec_`). Verify it on the **raw** body, compare in constant time, and reject old timestamps:

```ts title="Node.js"
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifySpaceContext(rawBody: string, header: string, secret: string, toleranceSeconds = 300): boolean {
  const parts = Object.fromEntries(header.split(',').map((part) => part.split('=') as [string, string]));
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  const received = Buffer.from(parts.v1 ?? '', 'hex');
  return received.length === expected.length && timingSafeEqual(received, expected);
}
```

```python title="Python"
import hashlib, hmac, time

def verify_spacecontext(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## Delivery

- **At least once.** An event can arrive more than once: use its `id` (also in `SpaceContext-Event-Id`) to ignore repeats. Ids are stable for the same fact: a scan completes once, so `evt_<scan>_completed` is sent once per endpoint even if the pipeline repeats a step.
- **Retries.** A failed delivery (no `2xx`, timeout, network error) is retried up to 8 times, waiting 30 seconds, then doubling up to an hour between attempts.
- **Auto-disable.** After 20 failed deliveries in a row the endpoint is turned off; turn it back on in the console.
- **Order** is not guaranteed.
- **Never redirected.** A `3xx` is a failure.

## Allowed URLs

`https` on port 443, with a public hostname: no IP addresses, no `localhost` or private names, no Space Context or Cloudflare hosts. The URL is checked when you add the endpoint and before every delivery. For local development, use a tunnel with a public HTTPS name.

## In the console

**Send test event** delivers a sample event (`data: { "test": true }`) and shows the answer. **Deliveries** lists recent attempts with their status and the last answer. Up to 10 endpoints per mode (10 for test events, 10 for live).

![Webhooks in the console: add an HTTPS endpoint for test or live events, choose scan.completed, scan.failed and wallet.auto_recharge_failed, and see your endpoints](https://spacecontext.app/docs/console/webhooks.jpg)

---

# The Space Document

Source: https://spacecontext.app/developers/docs/api/space-document/
Markdown: https://spacecontext.app/developers/docs/api/space-document.md

`GET /spaces/{id}` returns the current **Space Document** of a space; `GET /spaces/{id}/scans/{scan_id}` returns a past scan as it was recorded. The schema is versioned (`schema_version: "1.0"`) and is the same for spaces scanned with the app and through the API. The exact schema is in the [API reference](https://spacecontext.app/developers/docs/api/reference.md).

## Top level

| Field | |
| --- | --- |
| `space` | `id`, `source` (`app` or `api`), `name`, `description`, `space_type`, `created_at`, `updated_at` |
| `scan` | `id`, `analyzed_at`, `recorded_at`, `duration_seconds`, `device`, `frame_count` |
| `enrichment` | `level` (`base` or `deep`) and `status` (`not_requested`, `pending`, `complete`, `failed`); see [Concepts](https://spacecontext.app/developers/docs/concepts.md#the-space-document) |
| `summary` | A short description of the space |
| `confidence` | How sure the analysis is overall, 0 to 1 |
| `features` | Features of the space as a whole |
| `stats` | Counts of areas, items, issues, key locations, instructions, facts |
| `areas`, `items`, `issues`, `key_locations`, `instructions`, `facts`, `qa` | The records, below |
| `frames` | The evidence frames, with signed URLs |
| `continuity` | How this scan was checked against the previous one, or `null`: `previous_scan_id`, `absent_items` and `resolved_issues` (each with the frame of this scan that shows the place). See [Compare two scans](https://spacecontext.app/developers/docs/api/skills-and-diff.md#gone-or-just-not-filmed) |
| `coverage` | `warnings` (weak coverage, unreadable labels, withheld text), `not_documented` (what a reader would expect but the video did not show) and `checklist`: the key locations every frame was checked for, those of the space's type in their order (a flat: `electrical_panel`, `water_shutoff`, `water_heater`…; a car: `identification_plate`, `instrument_panel`, `engine_bay`…; see [Guided capture](https://spacecontext.app/developers/docs/guides/guided-capture.md#what-to-film-for-each-type-of-space)), each `seen` or not, with its `probability` and best frames. Not seen means not filmed: a hint for the next recording. Empty for scans whose frames were not checked. |
| `transcript` | Only with `include=transcript` and `spaces:sensitive` |

## Records

| Record | Main fields |
| --- | --- |
| **Area** | `id`, `name`, `type` (`kitchen`, `bathroom`, `bedroom`, …), `level`, `condition` (`code`, `notes`), `description`, `orientation` (one sentence from where one comes in: "Coming in from the hallway: window ahead, bed on the left"), `features`, `access_from`, `item_ids`, `issue_ids` |
| **Item** | `id`, `area_id`, `name`, `canonical_name`, `category`, `subcategory`, `quantity`, `description` (one paragraph: what it is, where it is, what was said about it, its condition), `location` (where in the area, as a sentence), `placement` (see below), `condition`, `brand`, `model`, `attributes`, `visible_text`, `aliases`, `note`, `issue_ids` (its issues), `previous_item_id` (the same object in the previous scan) |
| **Issue** | `id`, `area_id`, `item_id`, `title`, `description`, `category`, `severity` (`low`, `medium`, `high`, `critical`), `suggested_action`, `previous_issue_id` |
| **Key location** | `id`, `kind` (`water_shutoff`, `electrical_panel`, `gas_shutoff`, `water_heater`, `heating_control`, `router`, `fire_extinguisher`, `smoke_detector`, `emergency_exit`, `keys`, meters, `alarm_panel`, and for vehicles, boats, campers and appliances `identification_plate`, `instrument_panel`, `engine_bay`, `spare_wheel`, `safety_equipment`, `battery_isolator`, `bilge_pump`, `seacock`, `fuel_shutoff`, `water_tank`, `sprinkler_valve`, `control_panel`, `filter`, `utility_connections`, …), `label`, `area_id`, `description`, `location`, `placement`, `notes` |
| **Instruction** | `id`, `title`, `area_id`, `item_id`, `steps`, `source_quote` (what was said) |
| **Fact** | `id`, `kind` (`access`, `wifi`, `rule`, `contact`, `schedule`, `quirk`, `measurement`, `history`, `other`), `statement`, `value`, `sensitive`, `redacted` |
| **Q&A** | `question`, `answer`, `source` (`owner` or `generated`) |

Every record that comes from the video carries `evidence`: a list of `{ frame_id, timestamp_ms }` pointing into `frames`, and a `confidence` between 0 and 1. Condition codes are `new`, `excellent`, `good`, `fair`, `poor`, `damaged`, `not_working` and `unknown`.

Items, issues and key locations also carry `verification`: a second, independent check of the record on the frames it cites, `{ status, probability, frame_id }` with `status` `verified` (a frame clearly shows it), `unconfirmed` (its frames were checked and none clearly shows it) or `not_visual` (it cites no frame, or it is a fault or an absence no photo can show). `frame_id` is the frame that shows it best, also the first of its `evidence`. `null` where it was not computed. The demo space carries it too, computed on its published frames. See [Verified evidence](https://spacecontext.app/developers/docs/guides/verified-evidence.md).

## Evidence frames

Each entry of `frames` has `id`, `index`, `timestamp_ms` (its time in the video) and a signed `url` with its `url_expires_at`. The URL serves the original frame, unaltered. It expires: read the document again for a fresh one. `url` is `null` for frames a redacted document does not cite, and for past scans of spaces scanned with the app (a later recording replaced their images).

## Placements

Items and key locations say where they are as structured data, so an agent can reason about it instead of parsing a sentence:

```json
"placement": {
  "anchor": "entrance door",
  "anchor_item_id": "itm_1",
  "relation": "left_of",
  "viewpoint": "facing the entrance door from inside",
  "detail": "on the wooden panel, at chest height"
}
```

- `relation` is one of `on`, `inside`, `under`, `above`, `below`, `beside`, `left_of`, `right_of`, `in_front_of`, `behind`, `opposite`, `unknown`. The first six hold from anywhere; the others are meant from `viewpoint`, which is always present with them.
- A side comes from a frame that shows both the thing and its anchor. When no frame does, the relation is `beside`, never a guessed side.
- Someone coming in through a door has it behind them: a side given facing that door from inside is the other side for them. `location` and `description` say both when the anchor is a door ("left of the entrance door, facing it from inside; on your right as you come in").
- `anchor_item_id` is the item that is the anchor, when it is one of the space's items.
- `placement` is `null` for spaces scanned with the iPhone app and for documents produced before placements existed.

## Redacted documents

A key without `spaces:sensitive` reads the same structure with:

- sensitive facts' `statement` replaced and `value` set to `null` (`redacted: true`);
- the location of keys and other sensitive key locations hidden;
- the same values removed from every other text, together with values that look like credentials, email addresses or international phone numbers even when no fact marks them;
- no transcript, no owner answers, no `source_quote`;
- `space.description`, `summary`, the top-level `features`, area `layout` and the stored `coverage.warnings` left out: they were never screened (the warning about text addressed to an AI stays);
- frames that show sensitive content without a URL, and frames no record cites without one either.

Spaces scanned with the app are readable by such a key only after the deep pass has screened them. Until then the document keeps its shape and counts, without content, and says so in `coverage.warnings`.

---

# API errors

Source: https://spacecontext.app/developers/docs/api/errors/
Markdown: https://spacecontext.app/developers/docs/api/errors.md

Errors share one envelope: `{ "error": { "code", "message", "request_id", … } }`. Branch on `code`, never on `message`. Quote `request_id` when you contact support.

## Requests

| Code | Status | Meaning, and what to do |
| --- | --- | --- |
| `VALIDATION_ERROR` | 400 | A parameter or field is missing or invalid; the message says which. |
| `MISSING_AUTH_HEADER` | 401 | No `Authorization: Bearer …` header. |
| `INVALID_TOKEN` | 401 | The key does not exist or was revoked. |
| `USER_NOT_FOUND` | 401 | The account of the key was deleted. |
| `INSUFFICIENT_SCOPE` | 403 | The key lacks a scope; `required_scopes` lists it. |
| `PRO_REQUIRED` | 403 | Spaces scanned with the app need Space Context Pro (only when the service turns this rule on; today every account reads its app spaces). |
| `NOT_FOUND` | 404 | No such space, scan or record for this key (test and live data are separate). |
| `RATE_LIMIT_EXCEEDED` | 429 | Wait `retry_after` seconds (also in `Retry-After`, except for the daily limits of search, questions and frame checks). `daily_limit` comes with the daily allowance of new spaces or scans; `max_streams` when the account already holds its open event streams (poll `GET /scans/{id}` instead). On a live `POST /scans/{id}/start` it also means five scans of the account already failed today because of their footage (refunded): live scans resume at midnight UTC; check frames first with `POST /frames/check`. See [Limits](https://spacecontext.app/developers/docs/limits.md). |
| `INTERNAL_ERROR` | 500 | Retry later; if it persists, contact support with the request id. |

## Spaces, search and questions

| Code | Status | Meaning |
| --- | --- | --- |
| `ENRICHMENT_PENDING` | 409 | A space scanned with the app is being screened; retry in a minute, or use a key with `spaces:sensitive`. |
| `SEARCH_UNAVAILABLE` | 503 | Search is temporarily unavailable. |

When the deep pass of a space scanned with the app could not finish, the document stays at the base level with `enrichment.status: "failed"`; reading it again retries (a few times). `STALE_ANALYSIS` in that case means the space was recorded again in the meantime.

## Scans and uploads

| Code | Status | Meaning |
| --- | --- | --- |
| `INVALID_STATE` | 409 | The scan is not in a state for this (for example uploading to a started scan, deleting a space with a scan in progress). |
| `UPLOAD_INCOMPLETE` | 409 | Start refused: frames missing, or the video missing or of another size than declared. |
| `INVALID_CONTENT_TYPE` | 400 | On create: the video's `content_type` is not one the API reads (`video/mp4`, `video/quicktime`, `video/x-m4v`, `video/3gpp`, `video/webm`, `video/x-matroska`, `video/matroska`). Nothing is created or charged; convert the video to MP4 first ([Scans](https://spacecontext.app/developers/docs/api/scans.md#2-declare-the-scan)). |
| `INVALID_CONTENT_TYPE` | 415 | On an upload: a frame (or `POST /frames/check`) that is not a JPEG, PNG or WebP image, or a video sent with a `Content-Type` other than the declared one. |
| `FILE_TOO_LARGE` | 413 | A frame over 2 MB, or a video over 95 MB sent through the API itself (`max_bytes`): a bigger video goes only to the direct upload link. A video whose `Content-Length` is not the declared size is `400 VALIDATION_ERROR`. |
| `EMPTY_FILE` | 400 | The upload has no bytes. |
| `SCANS_IN_PROGRESS` | 429 | On start: ten scans of the account are already processing; start again when one finishes. On create: twenty scans are waiting for their upload (`max_open_scans`); upload and start them, or cancel some with `DELETE /scans/{id}`. |
| `INSUFFICIENT_FUNDS` | 402 | The balance does not cover the scan; `balance_cents` and `price_cents` say by how much. |
| `SPENDING_CAP_REACHED` | 402 | The key's monthly cap is reached. |
| `FRAME_CHECK_UNAVAILABLE` | 503 | `POST /frames/check` cannot run right now; try again shortly. |

A scan that fails after starting carries its reason in `scan.error.code`, and is refunded:

| `scan.error.code` | Meaning |
| --- | --- |
| `VIDEO_UNREADABLE` | The video could not be decoded, or its length could not be read. A WebM recorded in a browser (`MediaRecorder`) states none: an API with its own ffmpeg (`limits.max_video_bytes` of `GET /v1` above 100 MB) measures it; elsewhere remux it first (`ffmpeg -i in.webm -c copy out.webm`). |
| `VIDEO_TOO_LONG` | Over 10 minutes. |
| `FOOTAGE_UNUSABLE` | Every frame was too dark, blurred or covered, or showed no place (a drawing, a screen). Refused before the analysis; film again in good light. |
| `ANALYSIS_EMPTY` | No space was recognised in the frames. |
| `AI_ANALYSIS_FAILED`, `AI_OVERLOADED` | The analysis failed; scan again later. |
| `PROCESSING_TIMEOUT` | Processing took more than 2 hours. |
| `UPLOAD_EXPIRED` | Not started within 24 hours (status `cancelled`, never charged). |

## Terminal sign-in (`POST /auth/device/token`)

| Code | Status | Meaning |
| --- | --- | --- |
| `AUTHORIZATION_PENDING` | 400 | Not decided yet in the console; poll again after `interval` seconds. |
| `SLOW_DOWN` | 400 | Polled faster than `interval`; wait longer. |
| `DEVICE_CODE_EXPIRED` | 400 | The code lived its ten minutes; start again. |
| `DEVICE_CODE_INVALID` | 400 | Unknown device code, or the key was already handed out. |
| `ACCESS_DENIED` | 403 | Denied in the console. |

## Agents that pay (`POST /agent/credits`)

| Code | Status | Meaning |
| --- | --- | --- |
| `PAYMENT_REQUIRED` | 402 | No payment yet: the options are in the `PAYMENT-REQUIRED` header (and in `error.payment_required`). |
| `PAYMENT_INVALID` | 400 | `PAYMENT-SIGNATURE` is malformed or does not match what was asked (amount, network, asset, recipient, fee payer, signature); `reason` says which. |
| `PAYMENT_FAILED` | 402 | The facilitator refused or could not settle the payment (for example not enough USDC); nothing was charged. |
| `PAYMENT_FAILED` | 502 | The settlement could not be confirmed: it may have gone through. Write to support with `nonce` (Base) or `signature` (Solana). |
| `AGENT_KEY_REQUIRED` | 403 | `rotate_key`, or the first key of the other mode, without one of the account's current keys; nothing was charged. |
| `PAYMENTS_UNAVAILABLE` | 503 | The chosen network cannot be served right now; try later or pay on the other one. |

Details and examples: [Agents that pay (x402)](https://spacecontext.app/developers/docs/account/agent-payments.md#errors).

## Evidence frames

| Code | Status | Meaning |
| --- | --- | --- |
| `SIGNED_URL_INVALID` | 401 | Not a valid evidence link. |
| `SIGNED_URL_EXPIRED` | 401 | The link expired; read the document again for a fresh one. |
| `MEDIA_NOT_FOUND` | 404 | The frame no longer exists. |

## Console

| Code | Status | Meaning |
| --- | --- | --- |
| `INVALID_CREDENTIALS` | 401 | Wrong or expired sign-in code. |
| `SIGN_IN_UNAVAILABLE` | 503 | That sign-in provider is not configured on this deployment. |
| `PAYMENT_METHOD_REQUIRED` | 402 | Creating or starting a live scan (`POST /spaces/{id}/scans`, `POST /scans/{id}/start`), checking a live frame (`POST /frames/check`) or creating a live key with `scans:write` (`POST /keys`, or approving a terminal sign-in) needs a card on file (or a top-up already paid): add one under **Billing → Add a card** (nothing is charged), or create a test key or a key without `scans:write`. Connecting an assistant with scanning allowed shows the same choice. |
| `PAYMENTS_UNAVAILABLE` | 503 | Card payments are temporarily unavailable. |
| `PAYMENT_FAILED` | 502 | Stripe could not create the payment page; try again. |

---

# TypeScript SDK

Source: https://spacecontext.app/developers/docs/sdk/typescript/
Markdown: https://spacecontext.app/developers/docs/sdk/typescript.md

`@spacecontext/sdk` is a client for the API, typed from its OpenAPI contract. No runtime dependencies; it runs in Node.js 22.12+, Deno and Cloudflare Workers.

```bash
npm install @spacecontext/sdk
```

```ts
import { SpaceContext, SpaceContextError } from '@spacecontext/sdk';

const sc = new SpaceContext({ apiKey: process.env.SPACECONTEXT_API_KEY });
```

`apiKey` defaults to `SPACECONTEXT_API_KEY`, `baseUrl` to `SPACECONTEXT_BASE_URL` then the production API. `sc.mode` tells whether the key is `test` or `live`.

## One call

From a video to the space's document, following the analysis as it runs:

```ts
const { document, markdown } = await sc.scan('./flat.mp4', {
  name: 'Seaside flat',
  type: 'apartment',
  format: 'markdown',                       // also 'skill'; the document is always returned
  onProgress: (p) => console.log(p.type === 'scan' ? p.label : p.type),
});

console.log(document.items.length, 'items');
console.log(markdown);                      // one readable page of the whole space
```

`scan()` takes a path (a video, an image, or a folder of photos; Node only), a `Blob`/`File` with its type, `{ video, contentType }` or `{ frames: [...] }`. A video is MP4, MOV, M4V, 3GP, WebM or Matroska (`.mp4`, `.mov`, `.m4v`, `.3gp`, `.webm`, `.mkv`, or the types in `VIDEO_CONTENT_TYPES`), up to 4 GB and 10 minutes. `sc.limits()` reads the API's own limits from `GET /v1` (`{ maxVideoBytes, maxVideoSeconds, maxPhotos }`, kept for an hour; an API that does not state them takes 100 MB), and a bigger video is refused before anything is created (the CLI compresses it when ffmpeg is installed). When the limits cannot be read, `limits()` is `null` and nothing is guessed: the API decides when the scan is declared. A video from a path is read from disk as it uploads, never into memory. Pass `space: 'spc_…'` instead of `name` to scan an existing space again: the new scan is checked against the previous one. It creates the space, uploads, starts, streams the scan's events to `onProgress` (`space_created` with `spaceId`, `uploading`, `uploaded`, `started`, one `scan` per change of status or stage with its `stage` and `label`, `complete`) and returns `{ spaceId, scan, document }`, plus `markdown` or `skill` when asked. A scan that fails throws a `SpaceContextError` whose `code` is the scan's error (`FOOTAGE_UNUSABLE`, `ANALYSIS_EMPTY`…), with the scan in `details.scan`; its charge is refunded.

Other options: `externalId` (with `name`: the same id returns the same space), `language` (the language of the report: `en`, `it`, `es`, `fr`, `de`, `pt` or `nl`, default `en`, listed in `REPORT_LANGUAGES`; the narration can be in any language), `transcript` (with photos; videos are transcribed by the API), `recordedAt`, `device`, `timeoutMs` (default 20 minutes, then `TIMEOUT` while the scan goes on) and `signal`.

## Reading

```ts
for await (const space of sc.spaces.iterate()) console.log(space.id, space.name);

const document = await sc.spaces.get('spc_…');                     // Space Document
const items = document.items.filter((item) => item.category === 'appliance');

const { data } = await sc.search('spare light bulbs', { limit: 5 });
const answer = await sc.spaces.ask('spc_…', 'Is there a dishwasher?');
const diff = await sc.spaces.diff('spc_…', { from: 'scn_old…' });
const skill = await sc.spaces.skill('spc_…');                      // { name, files: [{ path, content }] }

const me = await sc.me();                                          // the account and the key's mode and scopes
const wallet = await sc.wallet();                                  // balance_cents, free scans left, scan_price_cents
const limits = await sc.limits();                                  // the largest and longest video, the most photos, on this API
const check = await sc.frames.check(bytes, 'image/jpeg', { spaceType: 'vehicle' }); // usable? which key locations? which area?
const profile = await sc.captureProfiles.get('vehicle');           // what to film for a type of space (sent without the key)
```

## Guided capture and verified evidence

```ts
import { fetchCaptureProfile, summarizeCoverage, summarizeVerification } from '@spacecontext/sdk';

const profile = await fetchCaptureProfile('boat', { language: 'it' }); // public: no key, no client
const coverage = summarizeCoverage(document, profile);  // seen, missingExpected, missingOptional
const evidence = summarizeVerification(document);       // verified, unconfirmed, notVisual, unchecked
```

`fetchCaptureProfiles()` and `fetchCaptureProfile(type)` read the [capture profiles](https://spacecontext.app/developers/docs/guides/guided-capture.md) with no key (from `SPACECONTEXT_BASE_URL` or the production API). `summarizeCoverage` reads the document's `coverage.checklist`; with the type's profile it splits what was not filmed into the key locations usually there and those only "if there is one". `summarizeVerification` counts the records [checked again on their own photo](https://spacecontext.app/developers/docs/guides/verified-evidence.md). `SPACE_TYPES` lists the eleven types of space.

## Scanning

```ts
import { describeScan, fileBody } from '@spacecontext/sdk';

const space = await sc.spaces.create({ name: 'Seaside flat', space_type: 'apartment' });

// Video: created, uploaded and started in one call. fileBody reads the file from disk as it
// uploads (a Blob, or a stream of its exact length from 4 GiB, which fs.openAsBlob miscounts).
const scan = await sc.scans.fromVideo(space.id, { data: await fileBody('flat.mp4'), contentType: 'video/mp4', language: 'en' });

// Or photos, in order:
// await sc.scans.fromFrames(space.id, [{ data: await fileBody('1.jpg'), contentType: 'image/jpeg', timestampMs: 0 }, …]);

const done = await sc.scans.wait(scan.id, { onProgress: (s) => console.log(s.status, s.stage) });
if (done.status !== 'complete') throw new Error(done.error?.message);

// Or every change as it happens, as an async iterator:
for await (const current of sc.scans.events(scan.id)) console.log(describeScan(current).label);
```

`wait` and `events` follow the API's event stream (`GET /scans/{id}/events`), reconnecting if it ends early or the connection drops. When no stream can be had (a proxy that does not stream, all 10 of the account's open streams in use, the stream failing), they poll `GET /scans/{id}` instead, every `intervalMs` (default 3 s) and less often, up to every 15 s, while nothing changes. At `timeoutMs` (default 20 minutes) they throw `TIMEOUT` with the last state in `details.scan`, even while the stream only sends pings, and close the stream. `describeScan(scan)` turns a status and stage into a label (`Looking at the frames`, `Extracting the context`, `Verifying the evidence`…).

If an upload fails, `fromVideo` and `fromFrames` cancel the scan (nothing is charged) and throw. The lower-level calls are there too: `scans.create`, `scans.uploadFrame`, `scans.uploadVideo`, `scans.start`, `scans.get`, `scans.cancel`.

## Exports

```ts
const page = await sc.spaces.markdown('spc_…');                 // one Markdown page, text
const skill = await sc.spaces.skill('spc_…');                   // { name, files: [{ path, content }] }
```

Both are built from exactly the document the key may read: a key without `spaces:sensitive` gets them redacted, and 409 `ENRICHMENT_PENDING` until the space is screened.

`sc.evidenceFrame(url)` downloads an evidence frame from a signed URL of the API (a document's `frames`, an answer's citations), without sending the key. It fetches nothing else: the frames of the demo space (test keys) are public files on `https://spacecontext.app/demo/brisbane/` and are refused with `INVALID_URL`; fetch those directly.

## Sign in from a tool

The CLI's `login` is in the SDK too, for any tool without a browser session:

```ts
import { deviceSignIn } from '@spacecontext/sdk';

const { api_key, user } = await deviceSignIn({
  name: 'My tool on this laptop',
  onCode: ({ userCode, verificationUrl }) => console.log(`Open ${verificationUrl} and enter ${userCode}`),
});
```

It asks the API for a code, shows it, and polls until the person approves it in the console; the key arrives here, never through the browser, and the code is never put in a link. `startDeviceSignIn()` and `waitForDeviceSignIn()` are the two halves. A `name` longer than 60 characters is cut; a poll that gets no answer is made again until the code expires, and every failure is a `SpaceContextError` (`NETWORK_ERROR` when the API cannot be reached). A live key that can scan needs a card on file: without one, the console asks the person to add it (nothing is charged) or to approve a key without `scans:write`.

## Errors

Every failure is a `SpaceContextError`:

```ts
try {
  await sc.spaces.ask('app_…', 'Where is the boiler?');
} catch (error) {
  if (error instanceof SpaceContextError) {
    error.status;      // HTTP status, 0 when there was no answer
    error.code;        // 'ENRICHMENT_PENDING', 'INSUFFICIENT_FUNDS', … or 'NETWORK_ERROR', 'TIMEOUT', 'FILE_READ_ERROR'
    error.requestId;   // quote it to support
    error.details;     // every field of the error, e.g. balance_cents
    error.retryAfter;  // seconds, from Retry-After
  }
}
```

See [all error codes](https://spacecontext.app/developers/docs/api/errors.md).

## Retries and safety

| Option | Default | |
| --- | --- | --- |
| `maxRetries` | 2 | Retries of a request refused for load or lost on the network |
| `maxRetryDelayMs` | 60 000 | A `429` asking to wait longer is returned at once (daily limits) |
| `timeoutMs` | 60 000 | Per request; uploads have no timeout |
| `fetch` | global `fetch` | A custom fetch, for tests or proxies |

- `429 RATE_LIMIT_EXCEEDED` is retried after `Retry-After` (`SCANS_IN_PROGRESS`, ten scans running, is returned at once); `502`–`504` and network errors only for requests that are safe to repeat. Scan creation sends an `Idempotency-Key`, reused on retry; `cancel` and `delete` are never repeated after a network error or a `5xx`.
- The API key is sent only to the API's own origin and path: never to a storage upload URL, an evidence URL or another host. Redirects are never followed.

## Types

Every schema of the contract is exported: `SpaceDocument`, `SpaceSummary`, `Scan`, `SearchResults`, `SpaceAnswer`, `SkillBundle`, `SpaceDiff`, `Wallet`, `FrameCheck`, `CaptureProfile`, `SpaceVerification`, `KeyLocationKind`, and the raw `components`, `paths` and `operations` from the OpenAPI document; `ScanSource`, `ScanOptions`, `ScanProgress` and `ScanResult` describe `scan()`.

---

# Install the CLI and sign in

Source: https://spacecontext.app/developers/docs/cli/install/
Markdown: https://spacecontext.app/developers/docs/cli/install.md

The CLI needs **Node.js 22.12** or later. Run it without installing:

```bash
npx spacecontext --help
```

or install it globally:

```bash
npm install -g spacecontext
spacecontext --version
```

## Sign in

```bash
spacecontext login            # a live key
spacecontext login --test     # a test key: free, the demo space
```

The terminal shows a code and opens the [console](https://spacecontext.app/developers/console/) (sign in with Apple, Google, GitHub or your email if you are not yet). Type the code there, check what is asking, choose what the key may do, and approve: the terminal receives its key and saves it in `~/.config/spacecontext/config.json` (or `$XDG_CONFIG_HOME/spacecontext/`), readable by your user only. The key never passes through the browser, and the console never sees your terminal.

A live key that can scan (`scans:write`) needs a card on file. If your account has none, the console tells you so instead of approving: add one under **Billing → Add a card** (nothing is charged) and approve again, or approve a key without `scans:write`. The terminal says this while it waits. `login --test` never needs a card.

Approve only a code you are looking at in your own terminal: the name shown in the console is what the terminal said about itself. The code is never carried in a link for that reason. `--no-browser` only prints the link.

Already have a key? `spacecontext login --key -` asks for it without echoing it, checks it with the API and saves it. Prefer it, or the code above, to `--key sc_…`: that works too, but leaves the key in your shell history.

```bash
spacecontext whoami      # account, the key in use (masked), its mode and scopes
spacecontext logout      # forget it
```

## Where the key comes from

The CLI uses the first of:

1. `--api-key sc_…` on the command line;
2. the `SPACECONTEXT_API_KEY` environment variable (handy in CI; empty counts as unset);
3. the key saved by `login`.

`--base-url` and `SPACECONTEXT_BASE_URL` point the CLI at another API base. A base URL saved by `login --base-url …` is used only together with the key saved with it, so a key from the environment never goes to a URL meant for another key.

## Output for scripts

Every command accepts `--json` and prints the API's JSON on standard output; progress goes to standard error. Exit codes: `0` success, `1` the request failed, `2` wrong usage. Errors print the API's code and request id:

```text
✗ Error [INSUFFICIENT_FUNDS]: The wallet does not cover this scan (request 8f0c2b9e-…)
  › Top up the balance in the console: https://spacecontext.app/developers/console/billing/
  › `spacecontext wallet` shows the balance and the price of a scan
```

Every error ends with what to do next: the console page to open, the command to run, or the variable to fix.

At a terminal the CLI uses colours and a live status line. When the output is piped or redirected it prints plain lines, and `NO_COLOR=1` (or `TERM=dumb`) turns colours off. `--json` output is never styled, and with `--json` standard error carries only errors (and the sign-in code of `login`).

Next: [the commands](https://spacecontext.app/developers/docs/cli/commands.md).

---

# CLI commands

Source: https://spacecontext.app/developers/docs/cli/commands/
Markdown: https://spacecontext.app/developers/docs/cli/commands.md

All commands accept `--json`, `--api-key <key>` and `--base-url <url>`. `spacecontext <command> --help` (or `spacecontext help <command>`) shows a command's options and examples, and an option a command does not take is refused rather than ignored. New here? Start with [`setup`](#get-started). Ids are the ones the API uses: spaces `app_…` (scanned with the app) or `spc_…` (created with the API or the CLI), scans `scn_…`.

## Get started

```bash
spacecontext setup [--client claude-code,codex,cursor,vscode,all] [--test] [--yes] [--json]
                   [--no-browser] [--skip-login] [--skip-mcp] [--skip-skill]
```

Everything in one go: signs in (the device sign-in of `login`, unless the key the CLI has is accepted), adds the MCP server to Claude Code, Codex, Cursor and VS Code (the ones it finds, or those named with `--client`), saves the skill as `~/.claude/skills/spacecontext/SKILL.md`, checks the card on file of a live account, and ends with what is left to do. Each step prints ✓ done, – skipped or ✗ failed with one line on why, and none stops the others.

| Option | |
| --- | --- |
| `--client <list>` | `claude-code`, `codex`, `cursor`, `vscode`, comma-separated, or `all`. Default: the clients found on this machine. A client named but not installed fails; with `all` it is skipped. |
| `--test` | Sign in with a test key (free, the demo space). |
| `--yes` | No questions and no browser, for agents. Also replaces a skill file you edited (kept as `SKILL.md.bak`) and a `spacecontext` server that points elsewhere in Claude Code (its user scope; a local or project one is reported, not removed), Codex or Cursor. Without `--yes` that server is kept and the step says skipped. |
| `--json` | Only the summary on standard output: `{ "steps": [{ "id", "status", "detail" }], "next": [...] }`. Progress and the sign-in code go to standard error. |
| `--no-browser` | Print links instead of opening them. |
| `--skip-login`, `--skip-mcp`, `--skip-skill` | Leave out a step. |

Claude Code gets the server for your user (`claude mcp add --transport http --scope user`); Codex through `codex mcp add`, and its MCP login runs in your terminal (not with `--yes` or `--no-browser`); Cursor in `~/.cursor/mcp.json`, written in one step, keeping your other servers, with the original copied once to `mcp.json.bak` (a file that is not plain JSON is left alone); VS Code through `code --add-mcp`. Programs are run directly, never through a shell, with a timeout; on Windows, where Codex and VS Code are batch files that need a shell, setup prints their command instead. The exit code is `0` when you are signed in and at least one assistant is connected (or those steps were skipped), `1` otherwise, `2` for wrong usage.

## Account

| Command | |
| --- | --- |
| `spacecontext login [--test] [--no-browser]` | Sign in from the terminal: a code to approve in the console, the key comes back here. `--key -` saves a key you already have, typed without echo (`--key sc_…` works but stays in your shell history). |
| `spacecontext logout` | Forget the saved key. |
| `spacecontext whoami` | The account, the key in use (masked, as the console shows it) and where it comes from, its mode (test or live) and scopes. |
| `spacecontext wallet` | Balance, free scans left, price per scan, card on file and automatic recharge. Needs `scans:read`. |

## Spaces

```bash
spacecontext spaces [--source app|api]
spacecontext space <space-id> [--transcript]
spacecontext delete-space <space-id>
```

`spaces` lists every space the key can read. `space` prints a summary (areas and their items, issues, coverage notes); add `--json` for the whole [Space Document](https://spacecontext.app/developers/docs/api/space-document.md). `--transcript` adds what was said in the video and needs `spaces:sensitive`. `delete-space` removes a space created with the API, with its scans and files; spaces scanned with the app are deleted in the app.

## Search and ask

```bash
spacecontext search <question…> [--space <space-id>] [--limit <1-25>]
spacecontext ask <space-id> <question…>
```

`search` looks across every space, in any language. `ask` answers from one space, with its sources, in English or Italian whatever language the scan was described in (with a test key, from the demo space, without a model):

```text
$ spacecontext ask spc_demo "where is the fuse box?"
The fuse box is in the Open-plan Living, Dining and Kitchen, in the hallway cupboard behind the coats.

Sources
  • Fuse box (Open-plan Living, Dining and Kitchen)

$ spacecontext ask spc_demo "dov'è il frigorifero?"
Dark refrigerator is in the Open-plan Living, Dining and Kitchen, at the left side of the kitchen work area. Condition: good.

Sources
  • Dark refrigerator (Open-plan Living, Dining and Kitchen)
```

## Skills and comparisons

```bash
spacecontext skill <space-id> [--out <dir>] [--transcript]
spacecontext markdown <space-id> [--out <file>] [--transcript]
spacecontext scans <space-id>
spacecontext diff <space-id> --from <scan-id> [--to <scan-id>]
```

`skill` writes the space as an Agent Skill folder (default: a folder named after the skill in the current directory). `markdown` prints the space as one readable page (or writes it with `--out`), to paste into a ticket or hand to a model as context. `scans` lists the scan history; `diff` compares two of them (`--to` defaults to the current scan) and prints what changed, keeping what is confirmed gone apart from what the later scan did not show ([why](https://spacecontext.app/developers/docs/api/skills-and-diff.md#gone-or-just-not-filmed)).

## Scans

`spacecontext checklist [--type <type>] [--language <code>]` prints what to film for a type of space (or, without `--type`, every type with its key locations "usually there" and "if there is one"): its key locations, those usually there first, each with a hint, and its areas. No key needed. `--json` prints the [capture profile](https://spacecontext.app/developers/docs/guides/guided-capture.md#what-to-film-for-each-type-of-space) as the API sends it.

`spacecontext check <image|frames-dir> [--type <type>]` checks images before a scan: which are usable, and which key locations (fuse box, stopcock, boiler, smoke detector…) they show, so what is missing can be filmed before paying for a scan. With `--type`, the key locations of that type of space (a car: identification plate, instrument panel, engine…) and the area each image shows, and what is missing is split into "usually there" and "if there is one"; an API older than the CLI, which ignores the type, is said on stderr. Free; needs `scans:write`. With `--json`, every frame's answer and a summary.

```bash
spacecontext scan <video-file | frames-dir> (--space <space-id> | --name <name>) [--type <type>] [--language <code>]
                  [--format text|json|markdown|skill] [--out <path>] [--no-wait] [--compress | --no-compress]
spacecontext scan-status <scan-id> [--follow]
spacecontext cancel <scan-id>
```

`scan` uploads a video (`.mp4`, `.mov`, `.m4v`, `.3gp`, `.webm`, `.mkv`, up to 4 GB and 10 minutes) or a folder of photos (`.jpg`, `.png`, `.webp`, up to 120, 2 MB each, taken in name order), starts the analysis and follows it live, printing each stage in words (`Reading the video`, `Looking at the frames`, `Extracting the context`…). With `--name` it first creates a space (`--type` is one of `apartment`, `house`, `office`, `warehouse`, `vehicle`, `boat`, `camper`, `shop`, `garage`, `appliance`, `other`); with `--space` it scans an existing space again, and the new scan is checked against the previous one. `--language` is the language of the report: `en` (English, the default), `it` (Italiano), `es` (Español), `fr` (Français), `de` (Deutsch), `pt` (Português) or `nl` (Nederlands), whatever language is spoken. `--format` chooses what to print when it is done: a summary (`text`), the whole document (`json`), one Markdown page (`markdown`, to stdout or `--out <file>`) or an Agent Skill folder (`skill`, written to `--out <dir>`). With `--no-wait` it returns once the scan has started; `scan-status --follow` picks it up again and prints every change until the end.

At a terminal, the stage in progress is a live line with its elapsed time, and each finished stage stays as a ✓ line with how long it took; piped, each stage is one plain `› stage…` line. At the end, the CLI prints the space (areas and items, key locations, issues), which key locations the recording filmed and which it did not (usually there, or if there is one, by the space's type), how many records were [verified on their photo](https://spacecontext.app/developers/docs/guides/verified-evidence.md) (`Evidence: 31 of 36 records verified on their photo · 3 unconfirmed · 2 not visual (from the narration, or a fault)`), and the commands to ask it, export it as a skill and read it again. `spacecontext space <id>` prints the same summary:

```text
$ spacecontext scan ./flat.mp4 --name "Seaside flat" --type apartment
◆ Scanning flat.mp4 (video, 48.2 MB) into a new space "Seaside flat"
✓ Space created  spc_3f9c…
✓ Uploaded flat.mp4  48.2 MB in 12s
✓ Scan started  scn_8a1b… · free scan
✓ Queued  2s
✓ Reading the video  21s
✓ Checking the frames  4s
✓ Looking at the frames  1m 04s
✓ Writing the report  38s
✓ Extracting the context  51s
✓ Saving  1s
✓ Scan complete in 3m 15s

Seaside flat  spc_3f9c… · api
Scan scn_8a1b… of 2026-10-11 · deep level, complete
5 areas · 42 items · 3 issues · 6 key locations · 9 facts
…
Next
  › Ask it: spacecontext ask spc_3f9c… "where is the fuse box?"
  › Give it to your agent: spacecontext skill spc_3f9c… --out ~/.claude/skills/seaside-flat
  › Read it again: spacecontext space spc_3f9c…
```

The limits are the ones the API states in `GET /v1`, read before anything is created (an API that does not state them takes 100 MB; when they cannot be read, nothing is checked or compressed unasked, and the API decides). A video over the limit is compressed to fit when `ffmpeg` and `ffprobe` are installed: an H.264 MP4 at the bitrate its duration allows (at most 8 Mbps, 720p under 2.5 Mbps), upright, uploaded and then deleted; a video over 10 minutes is refused before encoding. `--compress` re-encodes to a smaller H.264 file before uploading (useful on a slow connection), `--no-compress` never compresses. Without ffmpeg, a video over the limit is refused with how to install it. See [Scanning](https://spacecontext.app/developers/docs/cli/scanning.md#automatic-compression).

```text
$ spacecontext scan ./IMG_0042.MOV --name "Seaside flat" --compress
◆ Scanning IMG_0042.MOV (video, 390.0 MB) into a new space "Seaside flat"
✓ Compressed IMG_0042.MOV  390.0 MB → 176.4 MB, H.264 up to 1080p, in 1m 52s
✓ Space created  spc_3f9c…
✓ Uploaded IMG_0042.MOV  176.4 MB in 59s
…
```

Sizes are checked before anything is created. If an upload fails, the scan is cancelled (nothing is charged); a space created with `--name` stays, and the CLI tells you its id. If you press Ctrl+C after the scan has started, the scan goes on on the server, and the CLI prints the `scan-status --follow` command that picks it up again.

`cancel` cancels a scan that has not started. See [Scanning](https://spacecontext.app/developers/docs/cli/scanning.md) for recording tips and costs.

---

# Scanning a space

Source: https://spacecontext.app/developers/docs/cli/scanning/
Markdown: https://spacecontext.app/developers/docs/cli/scanning.md

## What you can upload

| Input | Limits |
| --- | --- |
| Video | MP4, MOV (including iPhone HEVC), M4V, 3GP, WebM or Matroska (`.mkv`); up to 4 GB and 10 minutes |
| Photos | Up to 120 JPEG, PNG or WebP, 2 MB each, in the order they were taken |
| Transcript (API only, optional) | Up to 600 segments of 2 000 characters, with their times |

From a video, the server takes 36 to 120 frames spread over its length (about one every 4 seconds) and transcribes the audio in whatever language is spoken. `--language` sets the language of the report: `en` (default), `it`, `es`, `fr`, `de`, `pt` or `nl`. Videos are deleted as soon as the frames are extracted; the frames are kept as evidence.

### Recording tips

A phone's default recording is fine as it is. At 1080p and 30 fps (16–17 Mbps, about 120 MB a minute) the full 10 minutes come to about 1.2 GB, and in 4K at 30 fps to about 3 GB, both within the 4 GB a video can be. A bigger file only takes longer to upload.

- **On a slow connection, let the CLI compress it.** With [ffmpeg](https://ffmpeg.org/download.html) installed (macOS: `brew install ffmpeg`; Debian or Ubuntu: `sudo apt install ffmpeg`; Windows: `winget install ffmpeg`), `spacecontext scan --compress` re-encodes the video to a smaller H.264 file before uploading it, and a video over the limit is compressed to fit without asking. See [below](#automatic-compression).
- **Keep clips short**, or trim what you do not need.

### Automatic compression

When a video is over the API's limit and `ffmpeg` and `ffprobe` are on the `PATH`, or with `--compress`, the CLI reads its duration and encodes a temporary copy before creating anything. The limit is the one the API states in `GET /v1` (4 GB on api.spacecontext.app), or 100 MB on an API that does not state it; when it cannot be read, only `--compress` compresses, at 8 Mbps:

- the bitrate fills 90% of the limit over the duration, at most 8 Mbps: under 4 GB that is always 8 Mbps (ten minutes come to about 600 MB); under 100 MB, 10 minutes get about 1.26 Mbps and a minute gets 8 Mbps;
- video in H.264 (`-preset veryfast`), its long side at most 1920 pixels, or 1280 when the bitrate is under 2.5 Mbps; a smaller video is never enlarged;
- audio in AAC, mono, 96 kbps; `+faststart`;
- a phone's rotation is applied, so the copy is upright.

A status line shows the progress; the copy is uploaded and then deleted, and your file is never changed. A video over 10 minutes is refused before encoding, since the API would refuse it too. `--compress` re-encodes a video that already fits to a smaller H.264 file, useful on a slow connection (when the copy comes out larger, the original is sent); `--no-compress` never compresses (a video over the limit is then refused). Without ffmpeg, a video over the limit is refused with how to install it.

A small copy in one command, without the CLI (long side 1280, about 1.3 Mbps in all: 10 minutes come to about 100 MB):

```bash
ffmpeg -i in.mov -vf "scale='if(gt(iw,ih),1280,-2)':'if(gt(iw,ih),-2,1280)'" \
  -c:v libx264 -preset veryfast -b:v 1200k -maxrate 1200k -bufsize 2400k \
  -c:a aac -b:a 96k -ac 1 -movflags +faststart out.mp4
```

## Record well

- **Walk slowly** and keep the camera steady; turn on the lights.
- **Name the rooms out loud** as you enter them ("this is the kitchen"): the narration ties frames to areas.
- **Say what matters**: where the water shut-off and the fuse box are, how the boiler or the washing machine works, which key opens what. Spoken instructions become how-to steps; spoken codes and passwords become facts marked sensitive, hidden from keys without `spaces:sensitive`.
- **Show labels up close** for a second: brand and model plates, meter numbers, the Wi-Fi sticker.
- **Open cupboards** whose content you want to find later.
- One video per space. Re-scan later and [compare](https://spacecontext.app/developers/docs/api/skills-and-diff.md) the two.

## What happens after the upload

1. **Start**: the scan is charged (the free scan first, then the balance; test keys are never charged).
2. **Ingesting** (videos): frames and audio are extracted.
3. **Screening**: every frame is checked once: usable or not, and which key locations it shows. Blurred, dark or covered frames are left out ([guided capture](https://spacecontext.app/developers/docs/guides/guided-capture.md) runs the same check while you film).
4. **Observing**: every usable frame is described.
5. **Fusing**: the observations become areas, items and issues.
6. **Enriching**: the deep pass structures everything, finds key locations, instructions and facts, and screens sensitive values.
7. **Complete**: the space shows the new scan, and the [webhook](https://spacecontext.app/developers/docs/api/webhooks.md) `scan.completed` is sent.

A scan usually takes a few minutes. If it fails (`VIDEO_UNREADABLE`, `VIDEO_TOO_LONG`, `FOOTAGE_UNUSABLE` when no frame is usable, `ANALYSIS_EMPTY` when no space is recognised, or a processing error), it is refunded and the `scan.failed` webhook is sent.

## Cost

$1.00 per live scan; the first live scan of a new account is free (one free scan per card). See [Billing](https://spacecontext.app/developers/docs/account/billing.md).

---

# Connect an assistant to the MCP server

Source: https://spacecontext.app/developers/docs/mcp/connect/
Markdown: https://spacecontext.app/developers/docs/mcp/connect.md

The Space Context MCP server gives an AI assistant access to your spaces: it can list them, search them, read them by section or as one page, ask them questions, compare scans and look at the evidence photos. By default it is **read-only**: it cannot change, delete or scan anything, and cannot spend money. When you connect, you can also let it **scan spaces** for you (and check photos): each scan then uses your free scan or is paid from your balance, and the assistant can tell you the price first. Allowing scans needs a card on file in your account; a read-only connection never does.

**Server URL:**

```text
https://mcp.spacecontext.app/mcp
```

**From a terminal:** `npx spacecontext@latest setup` adds the server to the Claude Code, Codex, Cursor and VS Code it finds, installs the skill and signs you in to the CLI ([Setup](https://spacecontext.app/developers/docs/setup.md#set-up-from-a-terminal)). The sections below do the same by hand, client by client.

It speaks MCP over Streamable HTTP and authorizes with OAuth 2.1 (dynamic client registration and client ID metadata documents), so any client that supports remote MCP servers with OAuth can connect. There is no key to paste: you sign in with Apple, Google or your email.

![Connect AI in the console: the server URL to copy, the steps for Claude, Claude Code, ChatGPT and Cursor, the assistants already connected, and questions to ask](https://spacecontext.app/docs/console/connect.jpg)

## What happens when you connect

1. Your assistant opens a Space Context page in the browser.
2. You sign in with Apple, with Google, or with your email and the one-time code we send you. It is the same account as the iPhone app.
3. You choose whether the assistant may also see **sensitive values** (Wi-Fi passwords, door and alarm codes, the narration of your videos), and whether it may **scan spaces** for you (this needs a card on file, see below). By default it may do neither.
4. You approve. Space Context creates an API key for this connection only, named after your assistant and marked **MCP** in the [console](https://spacecontext.app/developers/docs/account/api-keys.md). A read-only connection's key has `spaces:read` and `scans:read`; sensitive values add `spaces:sensitive`, scanning adds `scans:write`.

To disconnect, remove the connector in your assistant, or revoke its key in the console: the assistant is then asked to sign in again on its next request.

### Scanning needs a card on file

A connection allowed to scan gets a live key with `scans:write`, and such a key needs a [card on file](https://spacecontext.app/developers/docs/account/billing.md#add-a-card). If your account has none, the page says so instead of connecting, and you choose:

- **Add a card in the console** (the link opens **Billing**): Stripe saves it and nothing is charged; your bank may ask you to confirm it. Then come back to the page within ten minutes and choose **Try again with scanning**.
- **Connect read-only**: the assistant reads, searches and asks your spaces but cannot scan or spend. Connect again later to allow scanning.
- **Deny access**.

## Claude (claude.ai and Claude Desktop)

1. Open **Settings → Connectors**.
2. Choose **Add custom connector**.
3. Name it `Space Context` and paste the server URL.
4. Click **Connect** and complete the sign-in.

On Team and Enterprise plans, an owner may need to add the connector for the organization first. Then, in a chat, enable Space Context from the tools menu and ask, for example: *"In my seaside flat, where is the water shut-off?"*

## Claude Code

```bash
claude mcp add --transport http --scope user spacecontext https://mcp.spacecontext.app/mcp
```

`--scope user` makes it available in every project. Then run `/mcp` in Claude Code and choose **Authenticate** for `spacecontext`. To give Claude Code a space without a connection, you can also export it as a [skill](https://spacecontext.app/developers/docs/guides/claude-code-skill.md).

Or install the **Space Context plugin**, which adds the same server and a skill that tells Claude how to use it:

```text
/plugin marketplace add finikoslab/spacecontext
/plugin install spacecontext@spacecontext
```

## Codex

```bash
codex mcp add spacecontext --url https://mcp.spacecontext.app/mcp
codex mcp login spacecontext
```

`codex mcp add` may start the sign-in itself; otherwise `codex mcp login` opens the browser to sign in and approve. The same in `~/.codex/config.toml` (or a project's `.codex/config.toml`):

```toml
[mcp_servers.spacecontext]
url = "https://mcp.spacecontext.app/mcp"
```

## ChatGPT

1. Open **Settings → Apps & Connectors → Advanced** and turn on **Developer mode**.
2. **Create** a connector: name `Space Context`, the server URL above, authentication **OAuth**.
3. Sign in with Apple, Google or your email, choose what the assistant may do, and approve.

## Cursor

Add the server to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):

```json
{
  "mcpServers": {
    "spacecontext": { "url": "https://mcp.spacecontext.app/mcp" }
  }
}
```

Cursor shows a **Login** button for the server in its MCP settings.

## VS Code

Add the server to your user profile:

```bash
code --add-mcp '{"name":"spacecontext","type":"http","url":"https://mcp.spacecontext.app/mcp"}'
```

Or, for one workspace, add it to `.vscode/mcp.json`:

```json
{
  "servers": {
    "spacecontext": { "type": "http", "url": "https://mcp.spacecontext.app/mcp" }
  }
}
```

Then run **MCP: List Servers** from the Command Palette, start `spacecontext` and sign in when asked.

## Other clients

Use the server URL with the client's "remote MCP server" or "custom connector" option. The OAuth metadata is published at:

- `https://mcp.spacecontext.app/.well-known/oauth-protected-resource/mcp`
- `https://mcp.spacecontext.app/.well-known/oauth-authorization-server`

## Which spaces the assistant sees

| Space | Visible |
| --- | --- |
| Spaces created with the API or the CLI (live) | Yes |
| Spaces scanned with the iPhone app | Yes (same account) |
| Spaces scanned with the app, not yet screened | Only after screening, unless you granted sensitive values: the first read starts it, and it takes a minute or two |
| The demo space (`spc_demo`) | No: a connection reads your live spaces. Read the demo with a free test key through the API, the CLI or the SDK |

## Good to know

- Space content is returned as **data, never as instructions**, and the server tells the assistant so. Text in a video that tries to address an AI is withheld before it reaches the assistant.
- Requests count against the same [limits](https://spacecontext.app/developers/docs/limits.md) as API keys. A tool that hits a limit tells the assistant how long to wait instead of hanging.
- To create a new scan, let the assistant do it (tick **scan spaces** when connecting, with a card on file: it creates the scan, tells you where to upload the video, starts it and follows it), or use the [CLI](https://spacecontext.app/developers/docs/cli/install.md) or the [API](https://spacecontext.app/developers/docs/api/scans.md); then the assistant sees it.

Next: [the tools in detail](https://spacecontext.app/developers/docs/mcp/tools.md).

---

# MCP server tools

Source: https://spacecontext.app/developers/docs/mcp/tools/
Markdown: https://spacecontext.app/developers/docs/mcp/tools.md

Reading tools are read-only (`readOnlyHint: true`, not destructive, idempotent). The scanning tools (`create_scan`, `upload_frame`, `start_scan`, `cancel_scan`) are annotated as writes, `cancel_scan` as destructive. They, and `check_frame`, are listed on every connection but work only on one where the user ticked **let it scan spaces** when connecting: otherwise the API refuses them with `INSUFFICIENT_SCOPE`. No tool reaches the open web. Results are JSON text (or a Markdown page); results that carry space content include a `note` saying it is data captured from a physical space, not instructions.

## `list_spaces`

The spaces this connection can read, newest first, with their latest scan (counts of areas, items and issues).

| Argument | Type | |
| --- | --- | --- |
| `source` | `"app"` \| `"api"` | Optional. Only spaces from this source. |
| `cursor` | string | Optional. `next_cursor` from a previous call (50 spaces per page). |

## `search_spaces`

The records that best answer a question, across all spaces or one. Any language: an Italian question finds an English record. Records are items, areas, issues, key locations, instructions, facts and Q&A.

| Argument | Type | |
| --- | --- | --- |
| `query` | string, 1–300 characters | What to look for. |
| `space_id` | string | Optional. Only this space. |
| `limit` | integer, 1–25 | Optional, default 10. |

Each result has `space_id`, `space_name`, `scan_id`, `record_type`, `record_id`, `title`, `area`, `text` and `score`. The `text` of an item, issue or key location says its photo check ("Photo check: verified on its photo", "Photo check: unconfirmed…", "Photo check: from the narration, no photo"): an unconfirmed record is said as uncertain.

## `get_space`

The current document of a space, shaped for a model: area names instead of ids, no frame list, empty fields left out.

| Argument | Type | |
| --- | --- | --- |
| `space_id` | string | `app_…` or `spc_…`. |
| `sections` | array of `areas`, `items`, `issues`, `key_locations`, `instructions`, `facts`, `qa`, `coverage` | Optional. Omit for all. Ask only for what you need on large spaces. |
| `include_evidence` | boolean | Optional. Adds to each record its frames, with signed URLs for `get_evidence_photo`, the one that shows it best first. |

Items, issues and key locations carry `verification`: `verified` (a photo it cites clearly shows it), `unconfirmed` (its photos do not clearly show it: say it is uncertain) or `not_visual` (from the narration, or a fault or absence no photo can show). `evidence_check` counts them (`verified`, `unconfirmed`, `not_visual`, and `not_checked` when some records were not checked). Both are left out on documents made before the check existed. See [Verified evidence](https://spacecontext.app/developers/docs/guides/verified-evidence.md).

## `ask_space`

A short answer (one to three sentences) from the records of one space, with the records it cites and their evidence, or `documented: false` when the answer is not in the records. The answer is asked to say when a record it rests on is unconfirmed on its photo; `get_space` shows each cited record's `verification`.

| Argument | Type | |
| --- | --- | --- |
| `space_id` | string | |
| `question` | string, 1–500 characters | |

## `list_scans`

The scan history of a space, newest first, to choose two scans for `compare_scans`.

| Argument | Type |
| --- | --- |
| `space_id` | string |

## `compare_scans`

What changed between two scans of a space: items added, moved, recounted or in another condition, and new issues. Items `removed` and issues `resolved` are confirmed by a frame of the empty place; `not_seen` ones were only absent from the later scan, so the assistant is told to say they *may* be missing. Each entry has evidence frames of both scans, which `get_evidence_photo` can show.

| Argument | Type | |
| --- | --- | --- |
| `space_id` | string | |
| `from_scan_id` | string | The earlier scan. |
| `to_scan_id` | string | Optional. The later scan; default the current one. |

For spaces scanned with the app, past scans are not screened, so this needs a connection granted sensitive values.

## `get_evidence_photo`

The original video frame behind a record, as an image.

| Argument | Type | |
| --- | --- | --- |
| `url` | string | A signed evidence URL from `get_space` (with `include_evidence`) or `ask_space` citations. |

Evidence links expire after a while; on `SIGNED_URL_EXPIRED`, read the record again for a fresh link.

## `get_space_markdown`

The whole current document of a space as one Markdown page: summary, every area with its items, issues, key locations and how-to steps, then facts, Q&A, coverage and the recording. One call to load a space as context before many questions. Redacted like everything else the connection reads.

| Argument | Type | |
| --- | --- | --- |
| `space_id` | string | |
| `include_transcript` | boolean | Optional. Adds the narration; needs the sensitive-values access. |

## `get_scan_status`

Where a scan made with the API or the CLI is: `awaiting_upload`, `queued`, `processing` (with its stage), `complete`, `failed` or `cancelled`.

| Argument | Type |
| --- | --- |
| `scan_id` | string |

## `follow_scan`

Waits for a running scan and returns where it is, in words (`label`, for example "Looking at the frames"), with `final: true` once it is complete, failed or cancelled. Waits up to `max_wait_seconds`; a scan still running after that comes back with `final: false` and `next: "Still running: call follow_scan again."`, never as an error. Clients that pass a progress token get a progress notification at every stage.

| Argument | Type | |
| --- | --- | --- |
| `scan_id` | string | |
| `max_wait_seconds` | integer, 1–110 | Optional, default 55. |

## `check_frame`

One photo checked as a scan would check it: `usable` (sharp, lit, showing the space), `quality` (`score` 0–3 and `level`), `shows_space`, and the key locations it shows (`key_locations_seen`) or not (`key_locations_not_seen`). Without `space_type`, those of a home: electrical panel, water shut-off, boiler, smoke detector, extinguisher, router, thermostat, gas meter. With it, those of that type of space (`capture_checklist` lists them) and the `area` the photo shows, from a probability of 0.6; when the API ignores `space_type` (an API older than the server), the result has a `warning` and the key locations are a home's. Nothing is stored. Use it on a photo the user sends, or from a camera, to say what is still missing before a scan. Needs the scanning access.

| Argument | Type | |
| --- | --- | --- |
| `image_base64` | string | The image bytes (JPEG, PNG or WebP, at most 2 MB), base64 without a `data:` prefix. |
| `mime_type` | `image/jpeg` \| `image/png` \| `image/webp` | |
| `space_type` | `apartment`, `house`, `office`, `warehouse`, `vehicle`, `boat`, `camper`, `shop`, `garage`, `appliance` or `other` | Optional. The type of space being filmed. |

## `capture_checklist`

What to film for a type of space, before a scan: its key locations, those usually there (`usually_there`) and those only if there is one (`if_there_is_one`), each with a `label` and a one-line `hint`, and its `areas` (`value`, `label`). Give it to the user before they film; `check_frame` with the same `space_type` then confirms each photo. Works on every connection; nothing is charged. See [Guided capture](https://spacecontext.app/developers/docs/guides/guided-capture.md).

| Argument | Type | |
| --- | --- | --- |
| `space_type` | `apartment`, `house`, `office`, `warehouse`, `vehicle`, `boat`, `camper`, `shop`, `garage`, `appliance` or `other` | |
| `language` | `en`, `it`, `es`, `fr`, `de`, `pt` or `nl` | Optional, default `en`. Language of the labels and hints. |

## `wallet`

The prepaid balance (`balance_cents`), the free scans left, the price of one scan and whether automatic top-up is on. Tell the user before starting a scan. No arguments.

## `create_scan`

Declares a new scan and returns `upload`, exactly as the API gives it, and a `next` step. Frames are sent one by one with `upload_frame`. For a video, `upload` is a one-off `PUT` URL: when it points at storage (headers given, no key needed) the agent can `PUT` the bytes there; when it points at the API it needs the user's own key, which the server never hands out, so the agent asks the user to upload with the CLI or the console. Nothing is charged until `start_scan`. Everything the server can check (a video over 4 GB or of a type the API does not take, a missing `video` or `frames`) is refused before a `new_space` is created, and a space created for a scan the API then refuses is deleted again.

| Argument | Type | |
| --- | --- | --- |
| `space_id` | string | Scan this space again (the new scan is checked against the previous one). Either this or `new_space`. |
| `new_space` | `{ name, space_type? }` | Create a space for the scan. |
| `source` | `"video"` \| `"frames"` | |
| `video` | `{ content_type, content_length }` | With `source: "video"`: `video/mp4`, `video/quicktime`, `video/x-m4v`, `video/3gpp`, `video/webm`, `video/x-matroska` or `video/matroska`, and the exact size in bytes. Up to 4 GB and 10 minutes on api.spacecontext.app; the API states its limit in `GET /v1`. |
| `frames` | `{ count }` | With `source: "frames"`: how many images (1–120). |
| `language` | `"en"` \| `"it"` \| `"es"` \| `"fr"` \| `"de"` \| `"pt"` \| `"nl"` | Optional, default `en`: the language the report is written in (English, Italiano, Español, Français, Deutsch, Português, Nederlands). The narration can be in any language. |

## `upload_frame`

Sends one image of a frames scan, at its index (`0…count-1`), through the server, which holds the key. JPEG, PNG or WebP, at most 2 MB, base64. Sending an index again replaces that frame. Then `start_scan`.

| Argument | Type | |
| --- | --- | --- |
| `scan_id` | string | |
| `index` | integer | 0 to 119 |
| `image_base64` | string | The image bytes, base64, no `data:` prefix. |
| `mime_type` | `"image/jpeg"` \| `"image/png"` \| `"image/webp"` | |

## `start_scan`

Starts the analysis of an uploaded scan and charges it now: the free scan first, then the balance. Returns the scan with its `charge`. A scan that fails is refunded.

| Argument | Type |
| --- | --- |
| `scan_id` | string |

## `cancel_scan`

Cancels a scan that has not started: nothing is charged, its uploads are deleted. A scan already started cannot be cancelled.

| Argument | Type |
| --- | --- |
| `scan_id` | string |

## Resources

| URI | Type | |
| --- | --- | --- |
| `spacecontext://spaces` | `application/json` | The spaces this connection can read, with their latest scan and each one's resource URI. |
| `spacecontext://spaces/{space_id}` | `text/markdown` | The space as one page, the same as `get_space_markdown`. Listed once per space. |

## Prompts

| Name | Arguments | |
| --- | --- | --- |
| `check_out_report` | `space_id`, `from_scan_id`, `to_scan_id?` | Calls `compare_scans` and writes a report in three parts: confirmed changes (removed items and resolved issues seen in a frame, new issues), possibly missing (`not_seen`, never stated as gone), and items added, moved, recounted or in another condition. |
| `where_is` | `space_id`, `item` | Finds one thing with `search_spaces` (then `ask_space`), answers with area and location as recorded, and offers the evidence photo. |

## Errors

A tool that fails returns `isError: true` with the error code and a hint, for example:

| Code | Hint given to the assistant |
| --- | --- |
| `ENRICHMENT_PENDING` | The space is being screened; retry in about a minute. |
| `PRO_REQUIRED` | App spaces need Space Context Pro on this deployment (not the case today). |
| `INSUFFICIENT_SCOPE` | The user did not grant this access (sensitive values, or scanning); they can reconnect and tick it. |
| `INSUFFICIENT_FUNDS` | The balance does not cover a scan; top up in the console. |
| `FRAME_CHECK_UNAVAILABLE` | Photo checks are not available right now. |
| `RATE_LIMIT_EXCEEDED` | Wait the given number of seconds. |
| Key revoked | The connection ended; reconnect Space Context. |

See [all error codes](https://spacecontext.app/developers/docs/api/errors.md).

---

# Use a space in Claude Code

Source: https://spacecontext.app/developers/docs/guides/claude-code-skill/
Markdown: https://spacecontext.app/developers/docs/guides/claude-code-skill.md

A space exported as a **skill** is a folder of files that Claude Code (and any agent that reads skills) opens only when a conversation needs it. It works offline and needs no connection to Space Context once exported.

## Export

```bash
npx spacecontext skill spc_3f… --out ~/.claude/skills/seaside-flat
```

For a project-only skill, export into the project instead: `--out .claude/skills/seaside-flat`.

```text
seaside-flat/
├── SKILL.md              # when to use it, and how to read the files
├── space.json            # the whole Space Document
└── references/
    ├── overview.md
    ├── areas/01-kitchen.md …
    ├── items.md          # with the names people use
    ├── key-locations.md  # water shut-off, fuse box, boiler…
    ├── how-to.md
    ├── issues.md
    ├── facts.md
    ├── qa.md
    └── frames.md
```

## Use

Ask Claude Code about the space; it loads the skill when the question matches its description:

> Where is the fuse box in the seaside flat, and how do I reset the boiler?

`SKILL.md` tells the agent to treat the files as data, to name the area, and to say "not documented" rather than guess.

## Keep it current

Re-export after a new scan: the folder is overwritten file by file. A skill exported with a key without `spaces:sensitive` contains no passwords or codes; add `--transcript` with a key that has the scope to include what was said in the video.

## Prefer a live connection?

Connect the [MCP server](https://spacecontext.app/developers/docs/mcp/connect.md) instead: Claude Code then reads the latest version of every space you own, and can search across them.

---

# Check-in and check-out

Source: https://spacecontext.app/developers/docs/guides/check-in-check-out/
Markdown: https://spacecontext.app/developers/docs/guides/check-in-check-out.md

Two scans of the same space and one comparison give you an evidence-backed record of what changed: for holiday rentals, long lets, moves, equipment handovers or vehicle returns.

## 1. Before

```bash
npx spacecontext scan checkin.mp4 --name "Flat 4B — Rossi stay" --type apartment
```

Walk every room, open cupboards, film appliances and anything already damaged, and say it out loud ("small scratch on the table, already there").

## 2. After

Scan the same space again, with its id:

```bash
npx spacecontext scan checkout.mp4 --space spc_3f…
```

## 3. Compare

```bash
npx spacecontext scans spc_3f…                       # the two scan ids
npx spacecontext diff spc_3f… --from scn_checkin…        # add --json for the full answer
```

The check-out scan is analysed against the check-in one: the same objects keep their names, and something is reported **gone** only when a frame shows its place empty. The comparison lists items added, gone, moved, with a changed quantity or condition, issues new or fixed, and, apart, what the check-out scan simply did not show (`not_seen`): it may be missing, or not filmed. Every entry links to its evidence frames in both scans: the original frames, with their time in the video, never retouched.

![A space in the console after a check-out scan: the loose heater cable as a high-severity issue with its evidence frames, the fuse box and the water shut-off as key locations; History & compare holds the before/after](https://spacecontext.app/docs/console/space.jpg)

## With an agent

Connected through [MCP](https://spacecontext.app/developers/docs/mcp/connect.md), an assistant can do it for you:

> Compare the check-in and check-out scans of Flat 4B and list anything damaged or missing, with photos.

## Notes

- Comparisons match records by content, so the same room filmed from another angle is still the same room.
- Film the check-out the way you filmed the check-in: the same rooms, in the same order, with cupboards open. What the camera does not show ends up in `not_seen`, never in `removed`.
- A scan made while lights are off or too fast can miss things: check `coverage` in both documents before drawing conclusions.
- Keep both scans: deleting the space deletes its history.

---

# Guided capture: what to film, checked while filming

Source: https://spacecontext.app/developers/docs/guides/guided-capture/
Markdown: https://spacecontext.app/developers/docs/guides/guided-capture.md

A scan is only as good as what was filmed. Two things help the person filming get it right the first time:

- **Capture profiles** say what to film for each type of space: the **key locations** people and agents ask about first (the fuse box and stopcock of a flat, the VIN plate and dashboard of a car, the seacocks and battery switch of a boat, the rating plate of an appliance), each with a one-line hint, and the areas of that type of space.
- **`POST /frames/check`** looks at one frame in about half a second and says whether it is usable, which of those key locations it shows and, with a type of space, which area it shows. An app can show a checklist that ticks itself while the person walks around, and point at what is still missing before the scan is made.

The same profiles drive every scan: each frame of a scan is checked for the key locations of the space's type, and the document's `coverage.checklist` lists them, seen or not.

## What to film for each type of space

Key locations in **bold** are usually there (worth a reminder when not filmed); the others count "if there is one".

| Type | Key locations | Areas |
| --- | --- | --- |
| `apartment`, `house`, `other` | **Electrical panel**, **water shut-off**, **water heater**, smoke detector, fire extinguisher, **router**, **heating control**, gas meter | Entrance, hallway, living room, kitchen, dining room, bedroom, bathroom, laundry, storage, balcony, garden, garage, stairs, outside |
| `office` | **Electrical panel**, **fire extinguisher**, **emergency exit**, smoke detector, alarm panel, first-aid kit, **router**, air-conditioning control, heating control, water shut-off | Entrance, reception, open-plan office, office, meeting room, kitchen, bathroom, storage, server room, hallway, stairs, outside |
| `shop` | The same as an office | Entrance, sales floor, checkout, fitting room, stockroom, office, bathroom, storage, outside |
| `warehouse` | **Electrical panel**, **fire extinguisher**, **emergency exit**, sprinkler valve, alarm panel, first-aid kit, smoke detector, water shut-off, electricity meter | Entrance, loading dock, storage area, mezzanine, office, bathroom, outside |
| `garage` | **Electrical panel**, water shut-off, fire extinguisher, smoke detector, electricity meter, gas shut-off | Entrance, storage area, outside |
| `vehicle` | **Identification plate** (VIN), **instrument panel**, **engine**, spare wheel, safety equipment, fire extinguisher, first-aid kit | Front, rear and side (outside), front seats, rear seats, boot, engine compartment |
| `camper` | **Identification plate**, **instrument panel**, **electrical panel**, **gas shut-off**, battery and isolator, water heater, fresh-water tank, heating control, smoke detector, fire extinguisher, spare wheel, safety equipment | Cab, living area, kitchen, bathroom, bedroom, storage, front, rear and side (outside) |
| `boat` | **Electrical panel**, **battery and isolator**, **instrument panel**, **engine**, **fire extinguisher**, **safety equipment**, bilge pump, seacocks, fuel shut-off, gas shut-off, first-aid kit, identification plate | Deck, cockpit, cabin, galley, bathroom, engine room, outside |
| `appliance` | **Identification plate** (rating plate), **control panel**, filter, connections, water shut-off | — |

`GET /capture-profiles` returns them all and `GET /capture-profiles/{space_type}` one, with labels and hints in `en`, `it`, `es`, `fr`, `de`, `pt` or `nl` (`?language=`). A hint says what to film for that type of space: the identification plate of a car is its VIN plate, of an appliance its rating plate. They are public: no key, the same for everyone, cached for an hour.

```bash
curl "$API/capture-profiles/vehicle?language=en"
```

```json
{
  "object": "capture_profile",
  "space_type": "vehicle",
  "language": "en",
  "key_locations": [
    { "kind": "identification_plate", "label": "Identification plate", "hint": "Film the VIN plate (door pillar, under the bonnet or through the windscreen) up close, holding still.", "expected": true },
    { "kind": "instrument_panel", "label": "Instrument panel", "hint": "Film the dashboard switched on, with the odometer visible.", "expected": true }
  ],
  "areas": [{ "value": "exterior_front", "label": "Front (outside)" }]
}
```

## One frame

```bash
curl -X POST "$API/frames/check?space_type=vehicle" -H "$AUTH" -H "Content-Type: image/jpeg" --data-binary @frame.jpg
```

```json
{
  "object": "frame_check",
  "livemode": true,
  "space_type": "vehicle",
  "usable": true,
  "quality": { "score": 2.41, "level": "good" },
  "shows_space": 0.99,
  "area": { "value": "interior_front", "probability": 0.88 },
  "key_locations": [
    { "kind": "identification_plate", "probability": 0.03, "seen": false },
    { "kind": "instrument_panel", "probability": 0.97, "seen": true }
  ]
}
```

- `usable`: sharp, lit and showing the space. Unusable frames are what a scan leaves out.
- `quality.level`: `unusable`, `poor`, `good` or `excellent` (`score` is the expected level, 0 to 3).
- `shows_space`: probability that the frame shows the real thing being filmed (a room, a vehicle, a boat, an appliance), not a drawing, a document or a screen.
- `area`: the most likely area and its probability; show it from about 0.6. `null` without `space_type`, for an appliance, or when the frame says too little.
- `key_locations[].seen`: probability of at least 0.7, for each key location of the type, in its order.

Without `space_type` the check is the original one: the eight key locations of a home, no area. Nothing is stored. Checks are free within a daily allowance per account (2 000 by default) and need a key with `scans:write`; a live check needs a card on file, like a live scan; test keys get a fixed answer. The same image always gets the same answer.

## In an app that films

1. When filming starts, read the profile of the space's type and show its key locations, those usually there first, unticked.
2. Every few seconds, send the latest frame (a JPEG of about 800 px is plenty) to `POST /frames/check?space_type=…`. Skip frames that are dark or blurred on the device, and back off on `429` or `503`: the recording must never wait for a check.
3. Tick a key location the first time a frame shows it, and keep the time of its best frame. Show the area from 0.6.
4. Before the person stops, say what is still missing among those usually there ("Not filmed yet: water heater"), and let them keep filming.

## From the SDK

```ts
import { SpaceContext, fetchCaptureProfile } from '@spacecontext/sdk';

const profile = await fetchCaptureProfile('vehicle'); // no key needed
const sc = new SpaceContext();
const seen = new Set<string>();
for await (const frame of framesFromTheCamera()) {
  const check = await sc.frames.check(frame.bytes, 'image/jpeg', { spaceType: 'vehicle' });
  if (!check.usable) showHint('Hold still, more light');
  for (const location of check.key_locations) if (location.seen) seen.add(location.kind);
}
const missing = profile.key_locations.filter((entry) => entry.expected && !seen.has(entry.kind));
```

`sc.captureProfiles.list()` and `sc.captureProfiles.get(type)` read the same profiles from a client (the key is not sent with them). After a scan, `summarizeCoverage(document, profile)` splits what was not filmed into `missingExpected` and `missingOptional`.

## From the terminal

```bash
npx spacecontext checklist --type vehicle       # what to film, with a hint each
npx spacecontext check ./car --type vehicle     # are these photos usable, what do they show
```

```text
FILE        USABLE  QUALITY          AREA          SHOWS
dash-1.jpg  ✓ yes   good, 2.41       Front seats   Instrument panel
boot-2.jpg  ✓ yes   good, 2.10       Boot          Spare wheel
blur-3.jpg  ✗ no    unusable, 0.31   —

Usable: 2 of 3 frames
Seen: Instrument panel (dash-1.jpg), Spare wheel (boot-2.jpg)
Not seen yet, usually there: Identification plate, Engine.
Not seen yet, if there is one: Safety equipment, Fire extinguisher, First-aid kit.
› Film them before scanning, if the space has them
```

## From an assistant (MCP)

`capture_checklist` tells the user what to film for a type of space, on any connection. On a connection allowed to scan, `check_frame` with `space_type` checks a photo the user sends.

## In every scan

Scans run the same check on every frame, with the profile of the space's type: unusable frames are left out of the analysis, footage with no usable frame is refused and refunded (`FOOTAGE_UNUSABLE`), and the document's `coverage.checklist` lists each key location of the type, seen or not, with the frames that show it. A key location mentioned in the narration ("the fuse box is in the hall cupboard") gets its photo when a frame shows it. The records of the scan are then checked again on their own photos: see [Verified evidence](https://spacecontext.app/developers/docs/guides/verified-evidence.md).

## How accurate it is

Measured on 2026-10-13 on 191 hand-labelled photos (Wikimedia Commons, `gpt-6-luna`, threshold 0.7): precision 1.00 on the frames of their own type of space for every new key location with labelled examples, except safety equipment (0.89); "connections" has no labelled examples yet. The question "does it show the space" asked per type accepts 96% of real vehicle photos, 96% of boat photos, 88% of camper photos and all appliance photos, and refuses all nine drawings, plans, documents and screenshots; the building-only question used before called 34 of 102 such real photos "not a space". The eight key locations of a home were measured on 2026-10-06 (best F1 0.94 at 0.7 on 456 labelled answers).

---

# Verified evidence: every record checked on its own photo

Source: https://spacecontext.app/developers/docs/guides/verified-evidence/
Markdown: https://spacecontext.app/developers/docs/guides/verified-evidence.md

The analysis that writes a Space Document is generative: it can name an object the photo it cites does not show (a cupboard read as a fridge, an object from the narration placed over a frame of the hall). So after it, every **item**, **issue** and **key location** is checked again, by a second model that cannot write text: a closed question about the record, asked on the frames the record cites, answered with a probability. The same frame always gets the same answer.

## In the document

```json
"verification": { "status": "verified", "probability": 0.97, "frame_id": "frame-12" }
```

| `status` | Meaning | What an agent should do |
| --- | --- | --- |
| `verified` | One of the frames it cites clearly shows it (probability 0.7 or more) | Rely on it; its first evidence frame is the one that shows it best |
| `unconfirmed` | Its frames were checked and none clearly shows it | Say it is uncertain; never state it as fact |
| `not_visual` | Nothing a photo could confirm: it cites no frame (it comes from the narration), or it is a fault or an absence | Say where it comes from when it matters |

- `probability` is the best over its frames (`null` for `not_visual`); `frame_id` is the frame that showed it best, moved first in the record's `evidence`, so the photo shown first is the right one.
- Up to four of the frames a record cites are asked; a frame it does not cite is never asked, and never added to it.
- `verification` is `null` where it was not computed: the base level of a space, documents made before the check existed, or a check that could not run. The check never makes a scan fail.
- It is numbers only, so keys without `spaces:sensitive` see it too; a record withheld from them stays withheld.
- The demo space (`spc_demo`, read with test keys) carries it too, computed on its published frames: at check-in 34 records verified and 4 unconfirmed, at check-out 41 verified, 1 unconfirmed and 2 not visual. A test key shows exactly what a live document looks like.

## What is asked

| Record | Question |
| --- | --- |
| Item | "The image shows a *generic name* (described as "*name*"), even if only partly or in the background." |
| Issue | "The image shows *title* (*category*), even if only partly." Faults and absences (`malfunction`, `missing`) are `not_visual`, never asked. |
| Key location | The definition of its kind used by the [frame check](https://spacecontext.app/developers/docs/guides/guided-capture.md) ("The image clearly shows an electrical panel, fuse box…"). |

## How accurate it is

Measured on 2026-10-13 on the real check-out of the demo space (38 items, 4 issues, 36 frames):

- items: 38 of 38 verified on one of the frames they cite, and 0 false confirmations out of 227 room-specific items (toilet, bed, fridge, sofa…) asked on frames of other rooms. Asking "clearly shows *name*" instead verified 33 of 38 (2 of 227 false); asking the generic name alone verified 38 of 38 but confirmed 16 of 227 falsely;
- asking only the first frame a record cites missed 13 of 38: some of those were real citation mistakes of the analysis (a toilet cited on a frame that does not show it), which taking the best of its frames corrects;
- issues: 3 of 4 verified at 0.95–0.96; the fourth, written as a possibility ("Possible shower-frame darkening"), got 0.59 and stays `unconfirmed`, the honest answer;
- key locations: the definitions of the frame check, precision 1.00 at 0.7 except safety equipment (0.89); "connections" (behind an appliance) is not measured yet (measures of 2026-10-06 and 2026-10-13).

## Where it shows

- **API**: `verification` on `items`, `issues` and `key_locations` of every [Space Document](https://spacecontext.app/developers/docs/api/space-document.md); a scan shows `stage: verifying` ("Verifying the evidence" in the SDK, CLI and MCP) for the few seconds it takes.
- **Markdown and Skill**: a "Photo check" line on every record and a summary ("31 of 36 records verified on photo…"); the Skill tells the agent how to read it.
- **Search and ask**: each record's text says its photo check, and `ask` says when an answer rests on an unconfirmed record.
- **SDK**: `summarizeVerification(document)` counts verified, unconfirmed, not visual and unchecked records.
- **CLI**: `spacecontext space` and `spacecontext scan` print "Evidence: 31 of 36 records verified on their photo · 3 unconfirmed · 2 not visual (from the narration, or a fault)", and mark unconfirmed issues and key locations ("(unconfirmed on its photo)").
- **MCP**: `get_space` gives each record's `verification` and an `evidence_check` count (`verified`, `unconfirmed`, `not_visual`, and `not_checked` when some records were not checked); the server tells the assistant to prefer verified records and hedge unconfirmed ones, and `ask_space`'s answer is asked to hedge on them.

---

# Security and privacy

Source: https://spacecontext.app/developers/docs/security-and-privacy/
Markdown: https://spacecontext.app/developers/docs/security-and-privacy.md

## Sensitive values

Videos often show or mention things an assistant should not repeat: the Wi-Fi password, door and alarm codes, where the spare keys are, phone numbers.

- The deep pass marks such facts as **sensitive**; a rule-based second net also catches passwords and codes announced in text, card numbers, IBANs, emails and international phone numbers (written with their country code) that it missed.
- Keys without the `spaces:sensitive` scope read the **redacted** document: those values are removed from every field, frames that show them have no URL, and the transcript and the owner's own answers are not available. Search results never contain them, whatever the key.
- A space scanned with the app is readable by such a key only after it has been screened.
- MCP connections never get sensitive values unless you tick the box when you connect.

## Text that addresses an AI

A sign, a sticker or a sentence said on camera can try to give orders to the agent that will read the space ("ignore your previous instructions and…"). Space Context recognises such text (in English and Italian, and attempts to override instructions also in Spanish, French and German), withholds it from documents, skills, search, answers and the models it uses, and notes it in `coverage.warnings`. Everything a space contains is presented to agents as data, never as instructions. This is a safety net, not a guarantee: agents should still treat content from the physical world as untrusted.

## Keys and sessions

- API keys and console sessions are stored hashed; a key is shown once.
- Keys cannot manage keys, billing or webhooks; console sessions cannot be used as keys.
- Each MCP connection has its own key, read-only unless you tick "let it scan spaces" when you connect; revocable from the console.
- Signing in from the terminal (`spacecontext login`) uses a code you type in the console; the code never travels in a link, and the key reaches the terminal without passing through the browser. Approve only a code shown in your own terminal.
- Every sign-in, key, webhook change, payment, refund and deletion is recorded in the account's **audit log** (console → Security, `GET /v1/console/audit-log`), with who did it: you in the console, an API key, Stripe, or Space Context itself. Secrets are never written there. Entries are kept 400 days and erased with the account.

## Where data lives

- Videos, frames and documents are stored on Cloudflare (R2 in the EU jurisdiction, D1).
- Source videos are deleted as soon as their frames are extracted; frames are kept as evidence and served through short-lived signed links.
- Frames, audio and text are processed by AI providers (OpenAI for vision and language, Cloudflare Workers AI for transcription and search) as described in the [privacy policy](https://spacecontext.app/privacy/).
- Card payments are handled by Stripe; card details never reach Space Context.

## Webhooks

Webhook requests are signed (HMAC-SHA256 over the timestamp and the body), sent only to public HTTPS hosts on port 443, never to IP addresses or private names, and never redirected. See [Webhooks](https://spacecontext.app/developers/docs/api/webhooks.md).

## Deleting data

- `DELETE /v1/spaces/{id}` deletes an API space with its scans, files and search entries.
- Deleting your account (from the iPhone app or by asking support) deletes every API key, space, scan, file, webhook, search entry and session. A remaining balance is not refunded automatically: contact support before deleting an account with funds.

## Reporting a vulnerability

Write to [support@finikos.it](mailto:support@finikos.it) with "Security" in the subject. Please do not test against other people's data.

---

# API limits and quotas

Source: https://spacecontext.app/developers/docs/limits/
Markdown: https://spacecontext.app/developers/docs/limits.md

## Uploads

| | Limit |
| --- | --- |
| Video | MP4, MOV, M4V, 3GP, WebM or Matroska; up to 4 GB and 10 minutes (the duration read from the file) |
| Video through the API (when no direct upload link is offered) | 95 MB: a bigger video goes to the direct upload link |
| Photos per scan | 120, JPEG, PNG or WebP, 2 MB each |
| Transcript | 600 segments of up to 2 000 characters |
| Direct upload link | Valid 1 hour |
| Time to start a scan after creating it | 24 hours, then it is cancelled |

`GET /v1` states the limits of the deployment it answers for in `limits` (`max_video_bytes`, `max_video_seconds`, `max_photos`); the SDK, the CLI and the console read them there, take a deployment that does not state them to accept 100 MB, and check nothing when they cannot be read (the API then decides when the scan is declared). Ten minutes of a phone's default 1080p recording come to about 1.2 GB. On a slow connection, the [CLI](https://spacecontext.app/developers/docs/cli/scanning.md#automatic-compression) re-encodes a video to a smaller file before uploading it (`--compress`) when ffmpeg is installed.

## Rates

| | Limit |
| --- | --- |
| Requests per key | 120 per minute |
| Requests per IP address | 1 200 per minute |
| Search | 20 per minute and 1 000 per day per account |
| Questions (`ask`) | 20 per minute and 200 answered per day per account |
| Frame checks (`POST /frames/check`) | 2 000 per day per account (free) |
| Agent payments (`POST /agent/credits`) | 5 per minute per IP address; $1 to $500 per payment |
| Scans processing at once | 10 per account; more on request |
| New spaces (`POST /spaces`) | 200 per day per account, in each mode (test keys included) |
| New scans (`POST /spaces/{id}/scans`) | 500 per day per account, in each mode (test keys included) |
| Event streams (`GET /scans/{id}/events`) open at once | 10 per account, test and live together |
| Event streams opened | 20 per minute per account |
| Event stream length | 30 minutes per connection; reconnect to keep following |
| Evidence frames | Signed links expire; read the document again for fresh ones |

Exceeding a rate answers `429 RATE_LIMIT_EXCEEDED` with the seconds to wait in `retry_after`, and in the `Retry-After` header too for per-minute limits, new spaces and scans, and event streams:

- **Daily limits** reset at midnight UTC. New spaces and new scans answer with the time left until then and their allowance in `daily_limit`; a retry with the same `Idempotency-Key` (scans) or `external_id` (spaces) returns what was already created and does not count again. Search, questions and frame checks answer `retry_after: 3600`.
- **Event streams**: with 10 open, the next one answers `Retry-After: 30` and `max_streams: 10`. Close one, or poll `GET /scans/{id}` meanwhile; the SDK, the CLI and the MCP server do this for you.

## Account

| | Limit |
| --- | --- |
| Active API keys | 20 |
| Live keys with `scans:write` | Only with a card on file ([Billing](https://spacecontext.app/developers/docs/account/billing.md#add-a-card)) |
| Live scans | Only with a card on file or a top-up already paid |
| Webhook endpoints | 10 per mode (test and live) |
| Top-up | $10 to $500 per payment |
| Automatic top-up | At most 3 per day, 10 minutes apart |
| Console session | 30 days, in one browser tab |
| Email sign-in code | Valid 10 minutes |
| Terminal sign-in code (`spacecontext login`) | Valid 10 minutes; polled at most every 5 seconds |

## Results

| | Limit |
| --- | --- |
| Search results | 25 per request |
| Question length | 500 characters |
| Search query | 300 characters |
| Space list page | 50 spaces |

Need more? Write to [support](https://spacecontext.app/support/).

---

# API reference

Source: https://spacecontext.app/developers/docs/api/reference/
Markdown: https://spacecontext.app/developers/docs/api/reference.md

Every operation of the Space Context API v1: parameters, request bodies, responses and an example of each. Generated from the OpenAPI contract.

Base URL `https://api.spacecontext.app/v1`. JSON in `snake_case`, timestamps ISO 8601 (UTC), money in US cents. Authenticate with `Authorization: Bearer sc_live_…` (an API key) or, for the console operations, a console session. Errors come as `{ "error": { "code", "message", "request_id" } }`.

Machine-readable contract: https://spacecontext.app/developers/docs/openapi.json · typed client: `@spacecontext/sdk` (https://github.com/finikoslab/spacecontext).

## Spaces

Spaces and their documents: create, list, read, search within, ask, export, compare, delete.

### GET /spaces

**List the spaces this key can read: scanned with the app (live keys) and created through the API**

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `listSpaces`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer (≥ 1, ≤ 50) | no |  |
| `cursor` | query | string | no |  |
| `source` | query | string: "app" \| "api" | no | Only spaces from this source. Omitted: every space this key can read (app spaces need a live key; test keys see the demo space). |

**Responses**

- `200` — Spaces page (SpaceListResponse)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "data": [
    {
      "object": "space",
      "id": "spc_3f9a…",
      "source": "app",
      "name": "Seaside flat",
      "space_type": "apartment",
      "created_at": "2026-10-05T13:54:37.000Z",
      "updated_at": "2026-10-05T13:57:02.000Z",
      "latest_scan": {
        "id": "spc_3f9a…",
        "analyzed_at": "2026-10-05T13:57:02.000Z",
        "areas": 1,
        "items": 1,
        "issues": 1
      }
    }
  ],
  "next_cursor": "string"
}
```

### POST /spaces

**Create a space to scan through the API**

Creates a space in the key's mode (test or live). With an `external_id` already used in that mode, returns the existing space with 200 instead of creating another. An account creates at most 200 spaces a day in each mode (by default, test keys included; a replay does not count): past that, 429 `RATE_LIMIT_EXCEEDED` with `Retry-After` (seconds to midnight UTC). Needs `spaces:read` and `scans:write`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `createSpace`

**Request body** (`application/json`, required): SpaceCreateRequest

```json
{
  "name": "Seaside flat",
  "description": "…",
  "space_type": "apartment",
  "external_id": "ext_…"
}
```

**Responses**

- `200` — The space with this external_id (ApiSpace)
- `201` — Space created (ApiSpace)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "space",
  "id": "spc_3f9a…",
  "source": "api",
  "livemode": true,
  "name": "Seaside flat",
  "description": "…",
  "space_type": "string",
  "external_id": "ext_…",
  "created_at": "2026-10-05T13:54:37.000Z",
  "updated_at": "2026-10-05T13:57:02.000Z"
}
```

### GET /spaces/{id}

**Get the current document for a space**

Keys without the `spaces:sensitive` scope only read what the deep pass has screened for sensitive values. Until it has (`enrichment.level` is `base`), the document has the same shape with its content withheld: the lists are empty and `stats` gives the counts. Once `enrichment.level` is `deep`, sensitive facts are redacted, their values are removed from every other text, and images are given only for the frames that non-sensitive records cite as evidence. Fields the deep pass does not read (project description, summary, area layout) and raw first-hand sources (transcript, owner answers, narrator quotes) always require the scope.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `getSpace`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |
| `include` | query | string: "transcript" | no | Adds the transcript. Requires the `spaces:sensitive` scope; without it the request is refused with 403 `INSUFFICIENT_SCOPE`. |
| `enrich` | query | string: "auto" \| "none" | no |  |

**Responses**

- `200` — Space document (SpaceDocument)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "space_document",
  "schema_version": "1.0",
  "space": {
    "id": "spc_3f9a…",
    "source": "app",
    "name": "Seaside flat",
    "description": "…",
    "space_type": "apartment",
    "created_at": "2026-10-05T13:54:37.000Z",
    "updated_at": "2026-10-05T13:57:02.000Z"
  },
  "scan": {
    "id": "spc_3f9a…",
    "analyzed_at": "2026-10-05T13:57:02.000Z",
    "recorded_at": "2026-10-05T13:57:02.000Z",
    "duration_seconds": 1,
    "device": "string",
    "video_sha256": "string",
    "frame_count": 1
  },
  "enrichment": {
    "level": "base",
    "status": "not_requested",
    "updated_at": "2026-10-05T13:57:02.000Z"
  },
  "summary": "…",
  "confidence": 0,
  "stats": {
    "areas": 1,
    "items": 1,
    "issues": 1,
    "key_locations": 1,
    "instructions": 1,
    "facts": 1
  },
  "features": [
    "string"
  ],
  "areas": [
    {
      "id": "spc_3f9a…",
      "name": "Seaside flat",
      "type": "entrance",
      "level": "string",
      "condition": {
        "code": "new",
        "label": "…",
        "notes": "string"
      },
      "description": "…",
      "layout": "string",
      "orientation": "string",
      "features": [
        "string"
      ],
      "access_from": [
        "string"
      ],
      "item_ids": [
        "string"
      ],
      "issue_ids": [
        "string"
      ],
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "confidence": 0
    }
  ],
  "items": [
    {
      "id": "spc_3f9a…",
      "area_id": "are_…",
      "name": "Seaside flat",
      "canonical_name": "string",
      "description": "…",
      "category": "furniture",
      "subcategory": "string",
      "quantity": 1,
      "location": "string",
      "placement": {
        "anchor": "string",
        "anchor_item_id": "anc_…",
        "relation": "on",
        "viewpoint": "string",
        "detail": "string"
      },
      "condition": {
        "code": "new",
        "label": "…",
        "notes": "string"
      },
      "brand": "string",
      "model": "string",
      "attributes": [
        "string"
      ],
      "visible_text": [
        "string"
      ],
      "aliases": [
        "string"
      ],
      "note": "string",
      "issue_ids": [
        "string"
      ],
      "previous_item_id": "pre_…",
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "verification": {
        "status": "verified",
        "probability": 0,
        "frame_id": "fra_…"
      },
      "confidence": 0
    }
  ],
  "issues": [
    {
      "id": "spc_3f9a…",
      "area_id": "are_…",
      "item_id": "ite_…",
      "title": "…",
      "description": "…",
      "category": "damage",
      "severity": "low",
      "suggested_action": "string",
      "previous_issue_id": "pre_…",
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "verification": {
        "status": "verified",
        "probability": 0,
        "frame_id": "fra_…"
      },
      "confidence": 0
    }
  ],
  "key_locations": [
    {
      "id": "spc_3f9a…",
      "kind": "electrical_panel",
      "label": "…",
      "description": "…",
      "area_id": "are_…",
      "location": "string",
      "placement": {
        "anchor": "string",
        "anchor_item_id": "anc_…",
        "relation": "on",
        "viewpoint": "string",
        "detail": "string"
      },
      "notes": "string",
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "verification": {
        "status": "verified",
        "probability": 0,
        "frame_id": "fra_…"
      },
      "confidence": 0
    }
  ],
  "instructions": [
    {
      "id": "spc_3f9a…",
      "title": "…",
      "area_id": "are_…",
      "item_id": "ite_…",
      "steps": [
        "string"
      ],
      "source_quote": "string",
      "transcript_segment_ids": [
        "string"
      ],
      "confidence": 0
    }
  ],
  "facts": [
    {
      "id": "spc_3f9a…",
      "kind": "access",
      "statement": "…",
      "value": "string",
      "area_id": "are_…",
      "sensitive": false,
      "redacted": false,
      "transcript_segment_ids": [
        "string"
      ],
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "confidence": 0
    }
  ],
  "qa": [
    {
      "id": "spc_3f9a…",
      "question": "…",
      "answer": "…",
      "source": "owner",
      "category": "string",
      "area_id": "are_…"
    }
  ],
  "transcript": {
    "text": "…",
    "segments": [
      {
        "id": "spc_3f9a…",
        "start_ms": 1200,
        "end_ms": 1200,
        "text": "…"
      }
    ]
  },
  "frames": [
    {
      "id": "spc_3f9a…",
      "index": 1,
      "timestamp_ms": 1200,
      "url": "https://…",
      "url_expires_at": "2026-10-05T13:57:02.000Z"
    }
  ],
  "continuity": {
    "previous_scan_id": "pre_…",
    "absent_items": [
      {
        "item_id": "ite_…",
        "name": "Seaside flat",
        "note": "string",
        "evidence": [
          {
            "frame_id": null,
            "timestamp_ms": null
          }
        ]
      }
    ],
    "resolved_issues": [
      {
        "issue_id": "iss_…",
        "title": "…",
        "note": "string",
        "evidence": [
          {
            "frame_id": null,
            "timestamp_ms": null
          }
        ]
      }
    ]
  },
  "coverage": {
    "warnings": [
      "string"
    ],
    "not_documented": [
      "string"
    ],
    "checklist": [
      {
        "kind": "electrical_panel",
        "seen": true,
        "probability": 0,
        "evidence": [
          {
            "frame_id": "fra_…",
            "timestamp_ms": 1200
          }
        ]
      }
    ]
  }
}
```

### DELETE /spaces/{id}

**Delete a space created through the API, with its scans and files**

Not possible while one of its scans is processing (409). App spaces are deleted in the app. Needs `scans:write`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `deleteSpace`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |

**Responses**

- `204` — Deleted
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

### GET /spaces/{id}/scans

**List scans for a space**

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `listSpaceScans`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |

**Responses**

- `200` — Scan summaries (ScanListResponse)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "data": [
    {
      "object": "scan",
      "id": "spc_3f9a…",
      "space_id": "spc_3f9a…",
      "analyzed_at": "2026-10-05T13:57:02.000Z",
      "areas": 1,
      "items": 1,
      "issues": 1,
      "current": true
    }
  ]
}
```

### POST /spaces/{id}/scans

**Create a scan of a space created through the API**

Declares what will be uploaded (frames with their timestamps, or one video), the language of the report (`en`, `it`, `es`, `fr`, `de`, `pt`, `nl`; the narration can be in any language) and the optional transcript. The response says how to upload in `upload`; then call `POST /scans/{id}/start`. A live scan needs a card on file, asked here, before any upload link: 402 `PAYMENT_METHOD_REQUIRED` (added in the console, nothing charged), 503 `PAYMENTS_UNAVAILABLE` where payments are not configured; the same rule as the start. A test scan never needs one; its uploads go through the API (a video of at most 95 MiB) and are counted, never stored. The same `Idempotency-Key` returns the same scan. An account creates at most 500 scans a day in each mode (by default, test keys included; a replay does not count): past that, 429 `RATE_LIMIT_EXCEEDED` with `Retry-After` (seconds to midnight UTC). An account holds at most 20 scans created and not started (both modes): past that, 429 `SCANS_IN_PROGRESS` (`max_open_scans`) until some are started or cancelled. A test scan keeps only whether it had a transcript, never its text (its result is the demo). Needs `scans:write`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `createScan`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |
| `Idempotency-Key` | header | string (pattern ^[A-Za-z0-9_-]{8,64}$) | no |  |

**Request body** (`application/json`, required): ScanCreateRequest

```json
{
  "input": {
    "kind": "frames",
    "language": "en",
    "frames": [
      {
        "timestamp_ms": 1200
      }
    ],
    "video": {
      "content_type": "video/mp4",
      "content_length": 1
    },
    "transcript": {
      "text": "…",
      "segments": [
        {
          "start_ms": 1200,
          "end_ms": 1200,
          "text": "…"
        }
      ]
    },
    "recorded_at": "2026-10-05T13:57:02.000Z",
    "device": "string",
    "duration_seconds": 1
  }
}
```

**Responses**

- `200` — The scan created with this Idempotency-Key (Scan)
- `201` — Scan created (Scan)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `402` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)
- `503` — Error response (Error)

Example `200` response:

```json
{
  "object": "scan",
  "id": "spc_3f9a…",
  "space_id": "spc_3f9a…",
  "livemode": true,
  "status": "awaiting_upload",
  "stage": "ingesting",
  "input": {
    "kind": "frames",
    "language": "en",
    "frame_count": 1,
    "video": {
      "content_type": "string",
      "content_length": 1
    },
    "has_transcript": true
  },
  "charge": {
    "kind": "free_scan",
    "price_cents": 100,
    "refunded": false
  },
  "error": {
    "code": "string",
    "message": "string"
  },
  "upload": {
    "kind": "frames",
    "method": "PUT",
    "url": "https://…",
    "url_template": "string",
    "headers": {},
    "frame_count": 1,
    "max_bytes": 1,
    "content_types": [
      "string"
    ],
    "expires_at": "2026-10-05T13:57:02.000Z"
  },
  "created_at": "2026-10-05T13:54:37.000Z",
  "started_at": "2026-10-05T13:54:40.000Z",
  "completed_at": "2026-10-05T13:57:02.000Z"
}
```

### GET /spaces/{id}/scans/{scan_id}

**Get a base document for a specific scan**

A scan read by id is the snapshot as it was recorded. Past scans are not screened for sensitive values, so this operation requires the `spaces:sensitive` scope and answers 403 `INSUFFICIENT_SCOPE` without it.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `getSpaceScan`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |
| `scan_id` | path | string (pattern ^scn_) | yes |  |
| `include` | query | string: "transcript" | no | Adds the transcript. Requires the `spaces:sensitive` scope; without it the request is refused with 403 `INSUFFICIENT_SCOPE`. |

**Responses**

- `200` — Space document for the scan (SpaceDocument)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "space_document",
  "schema_version": "1.0",
  "space": {
    "id": "spc_3f9a…",
    "source": "app",
    "name": "Seaside flat",
    "description": "…",
    "space_type": "apartment",
    "created_at": "2026-10-05T13:54:37.000Z",
    "updated_at": "2026-10-05T13:57:02.000Z"
  },
  "scan": {
    "id": "spc_3f9a…",
    "analyzed_at": "2026-10-05T13:57:02.000Z",
    "recorded_at": "2026-10-05T13:57:02.000Z",
    "duration_seconds": 1,
    "device": "string",
    "video_sha256": "string",
    "frame_count": 1
  },
  "enrichment": {
    "level": "base",
    "status": "not_requested",
    "updated_at": "2026-10-05T13:57:02.000Z"
  },
  "summary": "…",
  "confidence": 0,
  "stats": {
    "areas": 1,
    "items": 1,
    "issues": 1,
    "key_locations": 1,
    "instructions": 1,
    "facts": 1
  },
  "features": [
    "string"
  ],
  "areas": [
    {
      "id": "spc_3f9a…",
      "name": "Seaside flat",
      "type": "entrance",
      "level": "string",
      "condition": {
        "code": "new",
        "label": "…",
        "notes": "string"
      },
      "description": "…",
      "layout": "string",
      "orientation": "string",
      "features": [
        "string"
      ],
      "access_from": [
        "string"
      ],
      "item_ids": [
        "string"
      ],
      "issue_ids": [
        "string"
      ],
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "confidence": 0
    }
  ],
  "items": [
    {
      "id": "spc_3f9a…",
      "area_id": "are_…",
      "name": "Seaside flat",
      "canonical_name": "string",
      "description": "…",
      "category": "furniture",
      "subcategory": "string",
      "quantity": 1,
      "location": "string",
      "placement": {
        "anchor": "string",
        "anchor_item_id": "anc_…",
        "relation": "on",
        "viewpoint": "string",
        "detail": "string"
      },
      "condition": {
        "code": "new",
        "label": "…",
        "notes": "string"
      },
      "brand": "string",
      "model": "string",
      "attributes": [
        "string"
      ],
      "visible_text": [
        "string"
      ],
      "aliases": [
        "string"
      ],
      "note": "string",
      "issue_ids": [
        "string"
      ],
      "previous_item_id": "pre_…",
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "verification": {
        "status": "verified",
        "probability": 0,
        "frame_id": "fra_…"
      },
      "confidence": 0
    }
  ],
  "issues": [
    {
      "id": "spc_3f9a…",
      "area_id": "are_…",
      "item_id": "ite_…",
      "title": "…",
      "description": "…",
      "category": "damage",
      "severity": "low",
      "suggested_action": "string",
      "previous_issue_id": "pre_…",
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "verification": {
        "status": "verified",
        "probability": 0,
        "frame_id": "fra_…"
      },
      "confidence": 0
    }
  ],
  "key_locations": [
    {
      "id": "spc_3f9a…",
      "kind": "electrical_panel",
      "label": "…",
      "description": "…",
      "area_id": "are_…",
      "location": "string",
      "placement": {
        "anchor": "string",
        "anchor_item_id": "anc_…",
        "relation": "on",
        "viewpoint": "string",
        "detail": "string"
      },
      "notes": "string",
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "verification": {
        "status": "verified",
        "probability": 0,
        "frame_id": "fra_…"
      },
      "confidence": 0
    }
  ],
  "instructions": [
    {
      "id": "spc_3f9a…",
      "title": "…",
      "area_id": "are_…",
      "item_id": "ite_…",
      "steps": [
        "string"
      ],
      "source_quote": "string",
      "transcript_segment_ids": [
        "string"
      ],
      "confidence": 0
    }
  ],
  "facts": [
    {
      "id": "spc_3f9a…",
      "kind": "access",
      "statement": "…",
      "value": "string",
      "area_id": "are_…",
      "sensitive": false,
      "redacted": false,
      "transcript_segment_ids": [
        "string"
      ],
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200
        }
      ],
      "confidence": 0
    }
  ],
  "qa": [
    {
      "id": "spc_3f9a…",
      "question": "…",
      "answer": "…",
      "source": "owner",
      "category": "string",
      "area_id": "are_…"
    }
  ],
  "transcript": {
    "text": "…",
    "segments": [
      {
        "id": "spc_3f9a…",
        "start_ms": 1200,
        "end_ms": 1200,
        "text": "…"
      }
    ]
  },
  "frames": [
    {
      "id": "spc_3f9a…",
      "index": 1,
      "timestamp_ms": 1200,
      "url": "https://…",
      "url_expires_at": "2026-10-05T13:57:02.000Z"
    }
  ],
  "continuity": {
    "previous_scan_id": "pre_…",
    "absent_items": [
      {
        "item_id": "ite_…",
        "name": "Seaside flat",
        "note": "string",
        "evidence": [
          {
            "frame_id": null,
            "timestamp_ms": null
          }
        ]
      }
    ],
    "resolved_issues": [
      {
        "issue_id": "iss_…",
        "title": "…",
        "note": "string",
        "evidence": [
          {
            "frame_id": null,
            "timestamp_ms": null
          }
        ]
      }
    ]
  },
  "coverage": {
    "warnings": [
      "string"
    ],
    "not_documented": [
      "string"
    ],
    "checklist": [
      {
        "kind": "electrical_panel",
        "seen": true,
        "probability": 0,
        "evidence": [
          {
            "frame_id": "fra_…",
            "timestamp_ms": 1200
          }
        ]
      }
    ]
  }
}
```

### GET /spaces/{id}/skill

**Export a space as a multi-file Agent Skill**

Returns the files of a ready-to-use skill built from the current document, exactly as this key may read it: a redacted document gives a redacted skill. `SKILL.md` is a short static guide; the knowledge is in `references/` and `space.json`, which an agent opens only when needed. A key without `spaces:sensitive` gets 409 `ENRICHMENT_PENDING` until the deep pass has screened the analysis.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `getSpaceSkill`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |
| `include` | query | string: "transcript" | no | Adds `references/transcript.md`. Requires the `spaces:sensitive` scope; without it the request is refused with 403 `INSUFFICIENT_SCOPE`. |
| `enrich` | query | string: "auto" \| "none" | no |  |

**Responses**

- `200` — Skill bundle (SkillBundle)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "skill",
  "name": "Seaside flat",
  "space_id": "spc_3f9a…",
  "scan_id": "scn_8a21…",
  "files": [
    {
      "path": "string",
      "media_type": "text/markdown",
      "content": "string"
    }
  ]
}
```

### GET /spaces/{id}/markdown

**Export a space as one Markdown page**

The current document as a single readable page (`text/markdown`): summary, every area with its items, issues, key locations and how-to steps, then facts, Q&A, coverage and the recording. Built from exactly the document this key may read, so a redacted document gives a redacted page; image links are never included. The same 409 `ENRICHMENT_PENDING` rule as the Skill export applies to keys without `spaces:sensitive`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `getSpaceMarkdown`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |
| `include` | query | string: "transcript" | no | Adds the transcript. Requires the `spaces:sensitive` scope; without it the request is refused with 403 `INSUFFICIENT_SCOPE`. |
| `enrich` | query | string: "auto" \| "none" | no |  |

**Responses**

- `200` — Markdown page (text/markdown)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

### POST /spaces/{id}/ask

**Ask a question about a space**

Answers from the records of the current document this key may read, in one to three sentences, with the records it relies on and their evidence images. Says so when the answer is not documented, and never reveals a redacted value. Test keys get the best-matching record of the demo, without a model. A key without `spaces:sensitive` gets 409 `ENRICHMENT_PENDING` until the space is screened. Limits: 20 a minute and, by default, 200 a day per account.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `askSpace`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |

**Request body** (`application/json`, required): object

```json
{
  "question": "…"
}
```

**Responses**

- `200` — Answer (SpaceAnswer)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "answer",
  "space_id": "spc_3f9a…",
  "scan_id": "scn_8a21…",
  "question": "…",
  "answer": "…",
  "documented": true,
  "citations": [
    {
      "record_type": "area",
      "record_id": "rec_…",
      "title": "…",
      "area": "string",
      "evidence": [
        {
          "frame_id": "fra_…",
          "timestamp_ms": 1200,
          "url": "https://…"
        }
      ]
    }
  ]
}
```

### GET /spaces/{id}/diff

**Compare two scans of a space**

What changed from the scan `from` to the scan `to` (the current one when omitted): areas, items added, removed, not seen, moved or changed, issues new, resolved or not seen. Computed without a model. When `to` was checked against `from` (consecutive scans of a space created through the API) records are matched by the links it made, and removals are confirmed by a frame of the empty place; otherwise records are matched by content (name, generic name, aliases) and what is missing is reported as `not_seen`. For a space scanned with the app, past scans are not screened for sensitive values, so this operation requires the `spaces:sensitive` scope.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `getSpaceDiff`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^(app\|spc)_) | yes |  |
| `from` | query | string (pattern ^scn_) | yes | Id of the earlier scan. |
| `to` | query | string (pattern ^scn_) | no | Id of the later scan. Defaults to the current one. |

**Responses**

- `200` — Differences between the two scans (SpaceDiff)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "space_diff",
  "space_id": "spc_3f9a…",
  "from": {
    "scan_id": "scn_8a21…",
    "analyzed_at": "2026-10-05T13:57:02.000Z"
  },
  "to": {
    "scan_id": "scn_8a21…",
    "analyzed_at": "2026-10-05T13:57:02.000Z"
  },
  "level": "base",
  "linked": true,
  "summary": {
    "areas_added": 0,
    "areas_removed": 0,
    "items_added": 0,
    "items_removed": 0,
    "items_not_seen": 0,
    "items_moved": 0,
    "items_changed": 0,
    "issues_new": 0,
    "issues_resolved": 0,
    "issues_not_seen": 0
  },
  "areas": {
    "added": [
      "string"
    ],
    "removed": [
      "string"
    ],
    "condition_changed": [
      {
        "area": "string",
        "from": "new",
        "to": "new"
      }
    ]
  },
  "items": {
    "added": [
      {
        "name": "Seaside flat",
        "canonical_name": "string",
        "area": "string",
        "quantity": 1,
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        }
      }
    ],
    "removed": [
      {
        "name": "Seaside flat",
        "canonical_name": "string",
        "area": "string",
        "quantity": 1,
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        },
        "note": "string"
      }
    ],
    "not_seen": [
      {
        "name": "Seaside flat",
        "canonical_name": "string",
        "area": "string",
        "quantity": 1,
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        }
      }
    ],
    "moved": [
      {
        "name": "Seaside flat",
        "canonical_name": "string",
        "area": "string",
        "quantity": 1,
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        },
        "from_area": "string"
      }
    ],
    "quantity_changed": [
      {
        "name": "Seaside flat",
        "canonical_name": "string",
        "area": "string",
        "quantity": 1,
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        },
        "from_quantity": 1
      }
    ],
    "condition_changed": [
      {
        "name": "Seaside flat",
        "canonical_name": "string",
        "area": "string",
        "quantity": 1,
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        },
        "from_condition": "new",
        "to_condition": "new"
      }
    ]
  },
  "issues": {
    "new": [
      {
        "title": "…",
        "area": "string",
        "severity": "low",
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        }
      }
    ],
    "resolved": [
      {
        "title": "…",
        "area": "string",
        "severity": "low",
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        },
        "note": "string"
      }
    ],
    "not_seen": [
      {
        "title": "…",
        "area": "string",
        "severity": "low",
        "evidence": {
          "from": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ],
          "to": [
            {
              "frame_id": null,
              "timestamp_ms": null,
              "url": null
            }
          ]
        }
      }
    ]
  }
}
```

## Scans

Declare a scan, upload its video or frames, start it, follow it live, cancel it.

### GET /scans/{id}

**Status of a scan**

Poll until `status` is `complete` or `failed`. A failed scan costs nothing: it is charged at start and refunded on failure, and `charge.refunded` says so. Needs `scans:read`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `getScan`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^scn_[0-9a-f]{32}$) | yes |  |

**Responses**

- `200` — Scan (Scan)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "scan",
  "id": "spc_3f9a…",
  "space_id": "spc_3f9a…",
  "livemode": true,
  "status": "awaiting_upload",
  "stage": "ingesting",
  "input": {
    "kind": "frames",
    "language": "en",
    "frame_count": 1,
    "video": {
      "content_type": "string",
      "content_length": 1
    },
    "has_transcript": true
  },
  "charge": {
    "kind": "free_scan",
    "price_cents": 100,
    "refunded": false
  },
  "error": {
    "code": "string",
    "message": "string"
  },
  "upload": {
    "kind": "frames",
    "method": "PUT",
    "url": "https://…",
    "url_template": "string",
    "headers": {},
    "frame_count": 1,
    "max_bytes": 1,
    "content_types": [
      "string"
    ],
    "expires_at": "2026-10-05T13:57:02.000Z"
  },
  "created_at": "2026-10-05T13:54:37.000Z",
  "started_at": "2026-10-05T13:54:40.000Z",
  "completed_at": "2026-10-05T13:57:02.000Z"
}
```

### DELETE /scans/{id}

**Cancel a scan that has not started**

Nothing was charged; its uploads are deleted. Needs `scans:write`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `cancelScan`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^scn_[0-9a-f]{32}$) | yes |  |

**Responses**

- `204` — Cancelled
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

### GET /scans/{id}/events

**Follow a scan in real time**

A `text/event-stream` (Server-Sent Events) that sends the scan (the same object as `GET /scans/{id}`, without `upload`) as an event named `scan` at once, then every time its status, stage or error changes, and closes after the final one (`complete`, `failed` or `cancelled`). A `ping` event every 15 seconds keeps the connection alive. One stream lasts at most 30 minutes; reconnect to keep following. An account holds at most 10 streams open at once (by default): one more answers 429 with `Retry-After`; close one, or poll `GET /scans/{id}`. Needs `scans:read`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `streamScanEvents`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^scn_[0-9a-f]{32}$) | yes |  |

**Responses**

- `200` — Event stream: `event: scan` with the scan as JSON data; `event: ping` with no data. (text/event-stream)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

### PUT /scans/{id}/frames/{index}

**Upload one frame of a frames scan**

The raw image as the body: JPEG, PNG or WebP (recognised by its bytes), at most 2 MB. Sending a frame again replaces it. A test scan checks the frame the same way and only records that it arrived: nothing of a test scan is stored (its result is the demo). Needs `scans:write`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `uploadScanFrame`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^scn_[0-9a-f]{32}$) | yes |  |
| `index` | path | integer (≥ 0, ≤ 119) | yes |  |

**Request body** (`image/jpeg, image/png, image/webp`, required): binary

**Responses**

- `200` — Frame stored (ScanUpload)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `413` — Error response (Error)
- `415` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "scan_frame",
  "scan_id": "scn_8a21…",
  "index": 1,
  "bytes": 1,
  "content_type": "string"
}
```

### PUT /scans/{id}/video

**Upload the video of a video scan through the API**

Only when `upload.url` points here (no direct upload link available; always for a test scan, whose video is counted and dropped, never stored). `Content-Length` and `Content-Type` must match what was declared, and the body must be that size. At most 99614720 bytes (95 MiB): a bigger video answers 413 `FILE_TOO_LARGE` and goes to the direct upload link instead. Needs `scans:write`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `uploadScanVideo`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^scn_[0-9a-f]{32}$) | yes |  |

**Request body** (`video/mp4, video/quicktime, video/x-m4v, video/3gpp, video/webm, video/x-matroska, video/matroska`, required): binary

**Responses**

- `200` — Video stored (ScanUpload)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `413` — Error response (Error)
- `415` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "scan_frame",
  "scan_id": "scn_8a21…",
  "index": 1,
  "bytes": 1,
  "content_type": "string"
}
```

### POST /scans/{id}/start

**Charge the scan and start the analysis**

Checks that every frame (or the video) is uploaded, uses the free scan or charges the wallet, and starts the analysis. Answers 202 when it started the scan and 200 when the scan had already started. A failed analysis is refunded. Errors: 402 `PAYMENT_METHOD_REQUIRED` (a live scan needs a card on file: added in the console, nothing charged), `INSUFFICIENT_FUNDS` or `SPENDING_CAP_REACHED`, 409 `UPLOAD_INCOMPLETE`, 429 `SCANS_IN_PROGRESS`, 429 `RATE_LIMIT_EXCEEDED` with `Retry-After` when 5 live scans of the account failed that day because of their footage (no usable frame, an unreadable or too long video, nothing recognisable: each refunded) until midnight UTC, 503 `PAYMENTS_UNAVAILABLE` (a live scan where payments are not configured). Test keys are never charged and get the demo result. Needs `scans:write`.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `startScan`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (pattern ^scn_[0-9a-f]{32}$) | yes |  |

**Responses**

- `200` — Already started (Scan)
- `202` — Started (Scan)
- `401` — Error response (Error)
- `402` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `409` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)
- `503` — Error response (Error)

Example `200` response:

```json
{
  "object": "scan",
  "id": "spc_3f9a…",
  "space_id": "spc_3f9a…",
  "livemode": true,
  "status": "awaiting_upload",
  "stage": "ingesting",
  "input": {
    "kind": "frames",
    "language": "en",
    "frame_count": 1,
    "video": {
      "content_type": "string",
      "content_length": 1
    },
    "has_transcript": true
  },
  "charge": {
    "kind": "free_scan",
    "price_cents": 100,
    "refunded": false
  },
  "error": {
    "code": "string",
    "message": "string"
  },
  "upload": {
    "kind": "frames",
    "method": "PUT",
    "url": "https://…",
    "url_template": "string",
    "headers": {},
    "frame_count": 1,
    "max_bytes": 1,
    "content_types": [
      "string"
    ],
    "expires_at": "2026-10-05T13:57:02.000Z"
  },
  "created_at": "2026-10-05T13:54:37.000Z",
  "started_at": "2026-10-05T13:54:40.000Z",
  "completed_at": "2026-10-05T13:57:02.000Z"
}
```

## Frames

Check one frame while a space is being filmed: usable or not, which key locations of its type it shows, and the area.

### POST /frames/check

**Check one frame while filming**

For apps that capture a space and guide the person filming: is this frame usable, which key locations does it show and, with `space_type`, which area. With `space_type` the key locations are those of that type of space (`GET /capture-profiles`: a car is checked for its VIN plate and dashboard, a boat for its seacocks), and `area` says where the frame was taken; without it, a home's: electrical_panel, water_shutoff, water_heater, smoke_detector, fire_extinguisher, router, heating_control, gas_meter, and no area. A key location is `seen` from a probability of 0.7. The raw image as the body: JPEG, PNG or WebP, at most 2 MB. About half a second; nothing is stored. Free within a daily allowance per account (429 when used up). Test keys get a fixed answer. Needs `scans:write`; a live check needs a card on file, like a live scan. Errors: 400 `VALIDATION_ERROR` (unknown `space_type`), 402 `PAYMENT_METHOD_REQUIRED` (live, no card on file: added in the console, nothing charged), 415 `INVALID_CONTENT_TYPE`, 503 `FRAME_CHECK_UNAVAILABLE`, 503 `PAYMENTS_UNAVAILABLE` (live, where payments are not configured).

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `checkFrame`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `space_type` | query | string: "apartment" \| "house" \| "office" \| "warehouse" \| "vehicle" \| "boat" \| "camper" \| "shop" \| "garage" \| "appliance" \| "other" | no | The type of space being filmed: its key locations, and the area question. |

**Request body** (`image/jpeg, image/png, image/webp`, required): binary

**Responses**

- `200` — What the frame shows (FrameCheck)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `402` — Error response (Error)
- `403` — Error response (Error)
- `413` — Error response (Error)
- `415` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)
- `503` — Error response (Error)

Example `200` response:

```json
{
  "object": "frame_check",
  "livemode": true,
  "space_type": "apartment",
  "usable": true,
  "quality": {
    "score": 0,
    "level": "unusable"
  },
  "shows_space": 0,
  "area": {
    "value": "entrance",
    "probability": 0
  },
  "key_locations": [
    {
      "kind": "electrical_panel",
      "probability": 0,
      "seen": true
    }
  ]
}
```

## Capture profiles

What to film for each type of space: its key locations, each with a hint, and the areas the frame check tells apart. Public, no key.

### GET /capture-profiles

**What to film, for every type of space**

For each type of space, its key locations (what people and agents ask about first: the fuse box of a flat, the VIN plate of a car, the seacocks of a boat), each with a one-line hint of what to film and whether it is usually there (`expected`), and its areas. The same for everyone: no key, cacheable. Use it to show a checklist while filming, with `POST /frames/check?space_type=`.

- Auth: public (no credentials)
- Operation id: `listCaptureProfiles`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `language` | query | string: "en" \| "it" \| "es" \| "fr" \| "de" \| "pt" \| "nl" | no | The language of the labels and hints: `en`, `it`, `es`, `fr`, `de`, `pt`, `nl` (default `en`). |

**Responses**

- `200` — Every capture profile (CaptureProfileList)
- `400` — Error response (Error)

Example `200` response:

```json
{
  "object": "list",
  "data": [
    {
      "object": "capture_profile",
      "space_type": "apartment",
      "language": "en",
      "key_locations": [
        {
          "kind": "electrical_panel",
          "label": "…",
          "hint": "string",
          "expected": true
        }
      ],
      "areas": [
        {
          "value": "entrance",
          "label": "…"
        }
      ]
    }
  ]
}
```

### GET /capture-profiles/{space_type}

**What to film, for one type of space**

One type of space: its key locations with a hint each, and its areas. Errors: 400 `VALIDATION_ERROR` (unknown language), 404 `NOT_FOUND` (unknown type).

- Auth: public (no credentials)
- Operation id: `getCaptureProfile`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `space_type` | path | string: "apartment" \| "house" \| "office" \| "warehouse" \| "vehicle" \| "boat" \| "camper" \| "shop" \| "garage" \| "appliance" \| "other" | yes |  |
| `language` | query | string: "en" \| "it" \| "es" \| "fr" \| "de" \| "pt" \| "nl" | no | The language of the labels and hints: `en`, `it`, `es`, `fr`, `de`, `pt`, `nl` (default `en`). |

**Responses**

- `200` — The capture profile (CaptureProfile)
- `400` — Error response (Error)
- `404` — Error response (Error)

Example `200` response:

```json
{
  "object": "capture_profile",
  "space_type": "apartment",
  "language": "en",
  "key_locations": [
    {
      "kind": "electrical_panel",
      "label": "…",
      "hint": "string",
      "expected": true
    }
  ],
  "areas": [
    {
      "value": "entrance",
      "label": "…"
    }
  ]
}
```

## Search

Search every space the key can read, in any language.

### GET /search

**Search across all your spaces**

Finds the records (items, areas, issues, key locations, instructions, facts, questions and answers) that best match a question in natural language, across every space this key can read, in any of 100+ languages: "where did I see a ladder?". Each result names its space, scan and record, so it can be opened with the other operations. The index holds only screened, redacted content: no result ever contains a sensitive value, whatever the key's scopes. A space appears once its current scan has been indexed: after a scan completes, or after the deep pass of a space scanned with the app. Test keys search the demo space, without a model. Limits: 20 a minute and, by default, 1000 a day per account.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `searchSpaces`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string (min 1, max 300) | yes |  |
| `space_id` | query | string (pattern ^(app\|spc)_) | no | Searches one space only. |
| `limit` | query | integer (≥ 1, ≤ 25) | no |  |

**Responses**

- `200` — Best matches first (SearchResults)
- `400` — Error response (Error)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)
- `503` — Error response (Error)

Example `200` response:

```json
{
  "object": "search_results",
  "query": "string",
  "data": [
    {
      "space_id": "spc_3f9a…",
      "space_name": "string",
      "scan_id": "scn_8a21…",
      "record_type": "area",
      "record_id": "rec_…",
      "title": "…",
      "area": "string",
      "text": "…",
      "score": 1
    }
  ]
}
```

## Media

Evidence frames, served through signed links.

### GET /media/{key}

**Serve a signed v1 evidence frame**

- Auth: public (no credentials)
- Operation id: `getEvidenceFrame`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `key` | path | string (pattern ^(frames\|api)/) | yes | Storage key of the frame, exactly as in the signed URL: `frames/…` for spaces scanned with the app, `api/…` for scans made through the API. |
| `token` | query | string | yes |  |
| `expires` | query | integer | yes |  |

**Responses**

- `200` — Original evidence frame bytes (image/jpeg)
- `401` — Error response (Error)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

## Wallet and billing

Balance, top-ups through Stripe, automatic top-up, ledger and invoices.

### GET /wallet

**What this key has to spend**

For agents: the balance, the free scans left, the price of a scan and whether a card is on file (`card_on_file`: live scans need one). A test key reads a fixed test-mode wallet instead (balance 0, no free scan, `card_on_file` false, automatic recharge off, the live price): test scans cost nothing and need no card. Needs the `scans:read` scope.

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `getWallet`

**Responses**

- `200` — Wallet (Wallet)
- `401` — Error response (Error)
- `403` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "object": "wallet",
  "currency": "usd",
  "balance_cents": 100,
  "free_scans_remaining": 0,
  "scan_price_cents": 100,
  "card_on_file": true,
  "tax_added": true,
  "auto_recharge": {
    "enabled": true,
    "threshold_cents": 100,
    "amount_cents": 100,
    "payment_method_saved": true,
    "last_error": "string"
  },
  "terms_accepted": {
    "version": "string",
    "accepted_at": "2026-10-05T13:57:02.000Z"
  }
}
```

## Account and console

Sign-in, the key in use, the console overview and the audit log.

### GET /me

**Get the current API key user**

- Auth: API key (`Authorization: Bearer sc_…`)
- Operation id: `getCurrentKey`

**Responses**

- `200` — Current API key user (MeResponse)
- `401` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "user": {
    "id": "spc_3f9a…",
    "email": "you@example.com",
    "is_pro": true
  },
  "key": {
    "id": "spc_3f9a…",
    "mode": "live",
    "scopes": [
      "spaces:read"
    ]
  }
}
```

### POST /auth/device

**Start a device sign-in (the CLI)**

A terminal that has no browser session asks for a code. It shows `user_code` and `verification_url` to the person, who opens the console and types the code (never a link with the code in it: a link could be sent by someone else to get their terminal approved), then polls `POST /auth/device/token` with `device_code` every `interval` seconds. The code lives `expires_in` seconds. Public; limited per IP.

- Auth: public (no credentials)
- Operation id: `startDeviceSignIn`

**Request body** (`application/json`): DeviceStartRequest

```json
{
  "name": "Seaside flat",
  "mode": "live"
}
```

**Responses**

- `201` — Codes for the terminal and the person (DeviceStart)
- `400` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `201` response:

```json
{
  "device_code": "string",
  "user_code": "BCDF-2345",
  "verification_url": "string",
  "expires_in": 1,
  "interval": 1
}
```

### POST /auth/device/token

**Poll a device sign-in**

Answers 400 `AUTHORIZATION_PENDING` until the person decides, 400 `SLOW_DOWN` when polled faster than `interval`, 400 `DEVICE_CODE_EXPIRED` after ten minutes, 403 `ACCESS_DENIED` when denied, and the new API key exactly once when approved. Public.

- Auth: public (no credentials)
- Operation id: `claimDeviceSignIn`

**Request body** (`application/json`, required): DeviceTokenRequest

```json
{
  "device_code": "string"
}
```

**Responses**

- `200` — The key, once (DeviceToken)
- `400` — Error response (Error)
- `403` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)

Example `200` response:

```json
{
  "api_key": "string",
  "key": {
    "id": "spc_3f9a…",
    "name": "Seaside flat",
    "mode": "live",
    "prefix": "string",
    "scopes": [
      "string"
    ],
    "monthly_cap_cents": 100,
    "created_at": "2026-10-05T13:54:37.000Z"
  },
  "user": {
    "id": "spc_3f9a…",
    "email": "you@example.com",
    "name": "Seaside flat",
    "is_pro": true,
    "is_admin": true
  }
}
```

## Service

The API itself.

### GET /

**API metadata**

Where the contract, the docs and the MCP server are, and the input limits of this deployment (`limits`).

- Auth: public (no credentials)
- Operation id: `getApiInfo`

**Responses**

- `200` — API metadata (ApiInfo)

Example `200` response:

```json
{
  "name": "space-context-api",
  "version": "v1",
  "openapi": "string",
  "docs": "string",
  "llms": "string",
  "llms_full": "string",
  "mcp": "string",
  "console": "string",
  "limits": {
    "max_video_bytes": 1,
    "max_video_seconds": 1,
    "max_photos": 1
  }
}
```

### GET /openapi.json

**OpenAPI document**

- Auth: public (no credentials)
- Operation id: `getOpenApiDocument`

**Responses**

- `200` — OpenAPI document

## Other

### POST /agent/credits

**Pay with x402 and get an API key (agents)**

For an agent with a wallet and no account: pay in USDC on **Base** or **Solana** with [x402](https://x402.org) v2 and receive an API key. No key, no sign-in: the payment is the credential, and the paying address is the account. Any x402 v2 client works (for example `@x402/fetch` with `@x402/evm` and `@x402/svm`): the network fee is paid by the facilitator, the payer needs only USDC.

1. Call without `PAYMENT-SIGNATURE`: 402 `PAYMENT_REQUIRED` with the `PAYMENT-REQUIRED` header (base64 JSON, also in the body as `payment_required`). `accepts` has one entry per network, Base first: scheme `exact`, the network (CAIP-2), the amount in atomic USDC units (6 decimals: US$1.00 = `1000000`), the USDC contract or mint, the address to pay, and `extra` (the EIP-712 domain of USDC on Base; on Solana `feePayer`, the facilitator account that pays the network fee). `extensions.bazaar` describes the call for x402 catalogs.
2. Pick one entry, sign it, and send **the same request, with the same body**, adding `PAYMENT-SIGNATURE` (base64 JSON PaymentPayload with that entry, unchanged, as `accepted`). On Base the payload is an EIP-3009 `transferWithAuthorization`; on Solana a transaction partially signed by the payer: SetComputeUnitLimit, SetComputeUnitPrice, a `TransferChecked` of exactly the amount into the USDC account of `payTo`, optional memo, fee payer `extra.feePayer`. The payment is verified and settled by the facilitator; the answer carries `PAYMENT-RESPONSE` with the transaction.

On mainnet (Base `eip155:8453`, Solana `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`) the payment is credited to the account's wallet (`amount_cents`, then US$1.00 per live scan; reading, search and answers are included; agents have no free scan; the credit does not expire) and the key is **live**. On testnets (Base Sepolia `eip155:84532`, Solana devnet `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) USDC is free, so the payment credits nothing: the key is a **test** key (demo space, no charges, no model). The same paying address always gets the same account; a Base wallet and a Solana wallet are two accounts.

**Store `api_key` from your first purchase: it is shown once.** A signed authorization can be submitted by whoever holds it, so a payment alone returns a key only when it opens the account. Later payments from the same wallet top up the same account and return only `api_key_prefix`. An existing key is replaced only with that key: `rotate_key: true` plus `Authorization: Bearer <the current key>` (the previous key is revoked). The first key of the other mode (e.g. a live key for an account that so far paid on testnet) needs `Authorization: Bearer <one of the account's current keys>`. Both are checked before any payment is taken (403 `AGENT_KEY_REQUIRED`, nothing charged). A payment replayed with the same transaction is recognised: nothing is credited or minted twice.

400 `PAYMENT_INVALID` when the signature header is malformed or does not match the requirements (amount, network, asset, address, fee payer, the payer's signature), before anything is sent to the facilitator. 402 `PAYMENT_FAILED` when the facilitator rejects or cannot settle the payment (`reason`; nothing was charged). 503 `PAYMENTS_UNAVAILABLE` when the chosen network cannot be served right now (nothing was charged). 404 when agent payments are off on this deployment. Limited per IP.

- Auth: public (no credentials)
- Operation id: `buyAgentCredits`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `PAYMENT-SIGNATURE` | header | string (max 16384) | no | base64 of the JSON x402 v2 PaymentPayload: `x402Version: 2`, `accepted` (one entry of `accepts`, unchanged), and `payload`: on Base `{ signature, authorization: { from, to, value, validAfter, validBefore, nonce } }`, on Solana `{ transaction }` (base64 of the partially signed transaction). |

**Request body** (`application/json`): AgentCreditRequest

```json
{
  "amount_cents": 100,
  "rotate_key": false
}
```

**Responses**

- `200` — Paid: the account, its credit (mainnet) and its key (AgentCredit)
- `400` — Invalid body, or `PAYMENT_INVALID` (AgentPaymentError)
- `402` — `PAYMENT_REQUIRED` (pay first) or `PAYMENT_FAILED` (rejected or not settled; nothing was charged) (AgentPaymentError)
- `403` — `AGENT_KEY_REQUIRED`: changing the keys of an existing account needs one of its current keys (nothing was charged) (AgentPaymentError)
- `404` — Error response (Error)
- `429` — Too many requests. Retry after the number of seconds in `Retry-After`. (Error)
- `500` — Settled but not recorded: reconciled by hand (`transaction`) (AgentPaymentError)
- `502` — The settlement could not be confirmed (`nonce` on Base, `signature` on Solana) (AgentPaymentError)
- `503` — Error response (Error)

Example `200` response:

```json
{
  "object": "agent_credit",
  "network": "eip155:8453",
  "payer": "string",
  "transaction": "string",
  "livemode": true,
  "amount_cents": 100,
  "balance_cents": 100,
  "api_key": "string",
  "api_key_prefix": "string",
  "notice": "string",
  "docs": "string"
}
```

## Objects

The fields of each object of the contract. Required fields are marked.

### ApiInfo

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | "space-context-api" | yes |  |
| `version` | "v1" | yes |  |
| `openapi` | string | yes |  |
| `docs` | string (uri) | no |  |
| `llms` | string (uri) | no |  |
| `llms_full` | string (uri) | no |  |
| `mcp` | string (uri) | no |  |
| `console` | string (uri) | no |  |
| `limits` | object | yes | What one scan may send to this deployment. |

### ConsoleUser

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `email` | string (email) | yes |  |
| `name` | string \| null | yes |  |
| `is_pro` | boolean | yes |  |
| `is_admin` | boolean | yes | True only for Space Context staff accounts, which the console shows extra internal pages; false for everyone else. |

### MeResponse

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `user` | object | yes |  |
| `key` | object | yes |  |

### SpacePlacement

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `anchor` | string \| null | yes | A fixed landmark the thing is placed against: a door, a window, the hob, a radiator. Never the thing itself. |
| `anchor_item_id` | string \| null | yes | The id of the item in this document that is the anchor, when the anchor is one of its items. |
| `relation` | PlacementRelation | yes | How a thing sits against its anchor. `left_of`, `right_of`, `in_front_of`, `behind` and `opposite` hold only from the placement's `viewpoint`; the others hold from anywhere. `unknown`: the recording does not show where it is. |
| `viewpoint` | string \| null | yes | Where a person stands and what they face for the relation to hold ("inside the flat, facing the entrance door"). Always set for a lateral relation. One canonical viewpoint per area: facing the entrance door from inside for an entrance or hallway, standing in the doorway one enters it from and looking in for any other area. An agent describing the place to someone mirrors left and right only when it knows that person's own viewpoint; otherwise it repeats this one. |
| `detail` | string \| null | yes | Height, the part of the anchor, or a second anchor: "at chest height, above the shoe rack". |

### SpaceEvidenceRef

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `frame_id` | string | yes |  |
| `timestamp_ms` | integer \| null | yes |  |

### SpaceVerification

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string: "verified" \| "unconfirmed" \| "not_visual" | yes |  |
| `probability` | number \| null (≥ 0, ≤ 1) | yes | Best probability over the frames checked. Null for `not_visual`. |
| `frame_id` | string \| null | yes | The frame that shows it best; it is also the first of the record's `evidence`. |

### SpaceFrame

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `index` | integer | yes |  |
| `timestamp_ms` | integer \| null | yes |  |
| `url` | string \| null (uri) | yes |  |
| `url_expires_at` | string \| null (date-time) | yes |  |

### SpaceCondition

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | ConditionCode | yes |  |
| `label` | string \| null | yes | The label as written in the language of the report ("Good", "Buono", "Bueno", "Gut"...); `code` is the same in every language. |
| `notes` | string \| null | yes |  |

### SpaceArea

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `type` | AreaType \| null | yes |  |
| `level` | string \| null | yes |  |
| `condition` | SpaceCondition | yes |  |
| `description` | string \| null | yes |  |
| `layout` | string \| null | yes |  |
| `orientation` | string \| null | yes | One sentence that places the main fixed things of the area from its canonical viewpoint (facing the entrance door from inside for an entrance or hallway; standing in the doorway one enters it from, looking in, for any other area): "Coming in from the hallway: window ahead, bed on the left, desk on the right." Null when the recording does not show enough, and always null at the base level. |
| `features` | array of string | yes |  |
| `access_from` | array of string | yes |  |
| `item_ids` | array of string | yes |  |
| `issue_ids` | array of string | yes |  |
| `evidence` | array of SpaceEvidenceRef | yes |  |
| `confidence` | number \| null (≥ 0, ≤ 1) | yes |  |

### SpaceItem

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `area_id` | string | yes |  |
| `name` | string | yes |  |
| `canonical_name` | string \| null | yes |  |
| `description` | string \| null | yes | What it is, where it is with the viewpoint, what was said about it (use, settings, quirks, what to watch out for), its condition when notable, in one paragraph of 1-3 sentences in the report language. Never a sensitive value. Null at the base level and on app spaces. |
| `category` | ItemCategory \| null | yes |  |
| `subcategory` | string \| null | yes |  |
| `quantity` | integer \| null (≥ 1) | yes |  |
| `location` | string \| null | yes | Where it is inside the area, as one sentence that carries the viewpoint when left and right matter: "on the wall left of the entrance door, facing the door from inside; on your right as you enter". |
| `placement` | SpacePlacement \| null | yes | The same place as anchor + relation + viewpoint. Null at the base level and on app spaces. |
| `condition` | SpaceCondition \| null | yes |  |
| `brand` | string \| null | yes |  |
| `model` | string \| null | yes |  |
| `attributes` | array of string | yes |  |
| `visible_text` | array of string | yes |  |
| `aliases` | array of string | yes |  |
| `note` | string \| null | yes |  |
| `issue_ids` | array of string | yes |  |
| `previous_item_id` | string \| null | yes | The same object in the previous scan (`continuity.previous_scan_id`), when this scan was checked against it. |
| `evidence` | array of SpaceEvidenceRef | yes |  |
| `verification` | SpaceVerification \| null | yes | Checked again on its frames. Null at the base level, on documents made before verification existed, and where it could not be computed. |
| `confidence` | number \| null (≥ 0, ≤ 1) | yes |  |

### SpaceIssue

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `area_id` | string \| null | yes |  |
| `item_id` | string \| null | yes |  |
| `title` | string | yes |  |
| `description` | string \| null | yes |  |
| `category` | IssueCategory \| null | yes |  |
| `severity` | IssueSeverity \| null | yes |  |
| `suggested_action` | string \| null | yes |  |
| `previous_issue_id` | string \| null | yes | The same issue in the previous scan, still there. |
| `evidence` | array of SpaceEvidenceRef | yes |  |
| `verification` | SpaceVerification \| null | yes | Checked again on its frames. Null at the base level, on documents made before verification existed, and where it could not be computed. |
| `confidence` | number \| null (≥ 0, ≤ 1) | yes |  |

### SpaceKeyLocation

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `kind` | KeyLocationKind | yes |  |
| `label` | string | yes |  |
| `description` | string \| null | yes | What it is, where it is with the viewpoint, what was said about it (how to reach or operate it, what to watch out for), its condition when notable, in one paragraph. Never a code or a password. Null at the base level and on app spaces. |
| `area_id` | string \| null | yes |  |
| `location` | string \| null | yes | Specific enough to walk straight to it, with the viewpoint when left and right matter. |
| `placement` | SpacePlacement \| null | yes | The same place as anchor + relation + viewpoint. Null at the base level and on app spaces. |
| `notes` | string \| null | yes |  |
| `evidence` | array of SpaceEvidenceRef | yes |  |
| `verification` | SpaceVerification \| null | yes | Checked again on its frames. Null at the base level, on documents made before verification existed, and where it could not be computed. |
| `confidence` | number \| null (≥ 0, ≤ 1) | yes |  |

### SpaceInstruction

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `title` | string | yes |  |
| `area_id` | string \| null | yes |  |
| `item_id` | string \| null | yes |  |
| `steps` | array of string | yes |  |
| `source_quote` | string \| null | yes |  |
| `transcript_segment_ids` | array of string | yes |  |
| `confidence` | number \| null (≥ 0, ≤ 1) | yes |  |

### SpaceFact

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `kind` | FactKind | yes |  |
| `statement` | string | yes |  |
| `value` | string \| null | yes |  |
| `area_id` | string \| null | yes |  |
| `sensitive` | boolean | yes |  |
| `redacted` | boolean | yes |  |
| `transcript_segment_ids` | array of string | yes |  |
| `evidence` | array of SpaceEvidenceRef | yes |  |
| `confidence` | number \| null (≥ 0, ≤ 1) | yes |  |

### SpaceQA

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `question` | string | yes |  |
| `answer` | string | yes |  |
| `source` | string: "owner" \| "generated" | yes |  |
| `category` | string \| null | yes |  |
| `area_id` | string \| null | yes |  |

### SpaceTranscriptSegment

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes |  |
| `start_ms` | integer | yes |  |
| `end_ms` | integer | yes |  |
| `text` | string | yes |  |

### SpaceContinuity

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `previous_scan_id` | string (pattern ^scn_) | yes |  |
| `absent_items` | array of object | yes |  |
| `resolved_issues` | array of object | yes |  |

### SpaceDocument

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string: "space_document" | yes |  |
| `schema_version` | string: "1.0" | yes |  |
| `space` | object | yes |  |
| `scan` | object | yes |  |
| `enrichment` | object | yes |  |
| `summary` | string \| null | yes |  |
| `confidence` | number \| null (≥ 0, ≤ 1) | yes |  |
| `stats` | object | yes |  |
| `features` | array of string | yes |  |
| `areas` | array of SpaceArea | yes |  |
| `items` | array of SpaceItem | yes |  |
| `issues` | array of SpaceIssue | yes |  |
| `key_locations` | array of SpaceKeyLocation | yes |  |
| `instructions` | array of SpaceInstruction | yes |  |
| `facts` | array of SpaceFact | yes |  |
| `qa` | array of SpaceQA | yes |  |
| `transcript` | object | no |  |
| `frames` | array of SpaceFrame | yes |  |
| `continuity` | SpaceContinuity \| null | yes |  |
| `coverage` | object | yes |  |

### SpaceSummary

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string: "space" | yes |  |
| `id` | string (pattern ^(app\|spc)_) | yes |  |
| `source` | string: "app" \| "api" | yes |  |
| `name` | string | yes |  |
| `space_type` | SpaceType | yes |  |
| `created_at` | string (date-time) | yes |  |
| `updated_at` | string (date-time) | yes |  |
| `latest_scan` | object \| null | yes |  |

### SpaceListResponse

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of SpaceSummary | yes |  |
| `next_cursor` | string \| null | yes |  |

### ScanSummary

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string: "scan" | yes |  |
| `id` | string (pattern ^scn_) | yes |  |
| `space_id` | string (pattern ^(app\|spc)_) | yes |  |
| `analyzed_at` | string \| null (date-time) | yes |  |
| `areas` | integer | yes |  |
| `items` | integer | yes |  |
| `issues` | integer | yes |  |
| `current` | boolean | yes |  |

### ScanListResponse

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array of ScanSummary | yes |  |

### SearchResults

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | "search_results" | yes |  |
| `query` | string | yes |  |
| `data` | array of object | yes |  |

### SpaceAnswer

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | "answer" | yes |  |
| `space_id` | string | yes |  |
| `scan_id` | string | yes |  |
| `question` | string | yes |  |
| `answer` | string | yes |  |
| `documented` | boolean | yes |  |
| `citations` | array of object | yes |  |

### SkillFile

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `path` | string | yes |  |
| `media_type` | string: "text/markdown" \| "application/json" | yes |  |
| `content` | string | yes |  |

### SkillBundle

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | "skill" | yes |  |
| `name` | string (max 64, pattern ^[a-z0-9]+(-[a-z0-9]+)*$) | yes |  |
| `space_id` | string | yes |  |
| `scan_id` | string | yes |  |
| `files` | array of SkillFile | yes |  |

### SpaceDiff

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | "space_diff" | yes |  |
| `space_id` | string | yes |  |
| `from` | object | yes |  |
| `to` | object | yes |  |
| `level` | string: "base" \| "deep" | yes |  |
| `linked` | boolean | yes | True when `to` was checked against `from` itself (consecutive scans of a space created through the API): its links and confirmed removals are used. Otherwise records are matched by content only, and nothing can be confirmed removed or resolved. |
| `summary` | object | yes |  |
| `areas` | object | yes |  |
| `items` | object | yes |  |
| `issues` | object | yes |  |

### Wallet

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | "wallet" | yes |  |
| `currency` | "usd" | yes |  |
| `balance_cents` | integer | yes |  |
| `free_scans_remaining` | integer (≥ 0) | yes |  |
| `scan_price_cents` | integer (≥ 1) | yes |  |
| `card_on_file` | boolean | yes | A real card stands behind the account: a card saved in the console, a paid top-up, or a settled x402 payment on mainnet. Starting a live scan, and creating a live key that can scan, need it: without it they answer 402 `PAYMENT_METHOD_REQUIRED`. Adding a card charges nothing. |
| `tax_added` | boolean | no | Whether tax is added on top of top-ups and automatic recharges where it applies (such as VAT), from the billing address and tax ID, and shown on the payment page and the invoice. A business with a valid VAT number in another EU country is invoiced under the reverse charge. The balance always receives the amount before tax. |
| `auto_recharge` | object | yes |  |
| `terms_accepted` | object \| null | no | Console only (absent from `GET /wallet`): the first time this account accepted the current version of the billing terms, with the box of a payment or card page; null if never, or only an older version. The box is still ticked on every page (`accept_terms`), and the first acceptance is kept on later payments for a dispute. |

### SpaceCreateRequest

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string (min 1, max 120) | yes |  |
| `description` | string \| null (max 2000) | no |  |
| `space_type` | string \| null: "apartment" \| "house" \| "office" \| "warehouse" \| "vehicle" \| "boat" \| "camper" \| "shop" \| "garage" \| "appliance" \| "other" \| "null" | no |  |
| `external_id` | string \| null (max 128) | no |  |

### ApiSpace

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | "space" | yes |  |
| `id` | string (pattern ^spc_[0-9a-f]{32}$) | yes |  |
| `source` | "api" | yes |  |
| `livemode` | boolean | yes |  |
| `name` | string | yes |  |
| `description` | string \| null | yes |  |
| `space_type` | string \| null | yes |  |
| `external_id` | string \| null | yes |  |
| `created_at` | string \| null (date-time) | yes |  |
| `updated_at` | string \| null (date-time) | yes |  |

### ScanCreateRequest

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `input` | object | yes |  |

### Scan

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | "scan" | yes |  |
| `id` | string (pattern ^scn_[0-9a-f]{32}$) | yes |  |
| `space_id` | string | yes |  |
| `livemode` | boolean | yes |  |
| `status` | string: "awaiting_upload" \| "queued" \| "processing" \| "complete" \| "failed" \| "cancelled" | yes |  |
| `stage` | string \| null: "ingesting" \| "screening" \| "observing" \| "fusing" \| "enriching" \| "saving" \| "verifying" \| "null" | yes |  |
| `input` | object | yes |  |
| `charge` | object | yes |  |
| `error` | object \| null | yes |  |
| `upload` | object \| null | yes |  |
| `created_at` | string \| null (date-time) | yes |  |
| `started_at` | string \| null (date-time) | yes |  |
| `completed_at` | string \| null (date-time) | yes |  |

### ScanUpload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string: "scan_frame" \| "scan_video" | yes |  |
| `scan_id` | string | yes |  |
| `index` | integer | no |  |
| `bytes` | integer | yes |  |
| `content_type` | string | no |  |

### FrameCheck

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string: "frame_check" | yes |  |
| `livemode` | boolean | yes |  |
| `space_type` | string \| null: "apartment" \| "house" \| "office" \| "warehouse" \| "vehicle" \| "boat" \| "camper" \| "shop" \| "garage" \| "appliance" \| "other" \| "null" | yes | The `space_type` asked, or null. |
| `usable` | boolean | yes | Sharp, lit and showing the space: worth keeping in a scan. |
| `quality` | object | yes |  |
| `shows_space` | number (≥ 0, ≤ 1) | yes | Probability that the frame shows the real thing being filmed (a place, a vehicle, a boat, an appliance), not a drawing, document or screen. |
| `area` | object \| null | yes | The most likely area of the space (`GET /capture-profiles`), with its probability: show it from about 0.6. Null without `space_type`, for a type with no areas (appliance), or when the frame shows none of them. |
| `key_locations` | array of object | yes | Every key location of the type of space, in its order. |

### CaptureProfile

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string: "capture_profile" | yes |  |
| `space_type` | string: "apartment" \| "house" \| "office" \| "warehouse" \| "vehicle" \| "boat" \| "camper" \| "shop" \| "garage" \| "appliance" \| "other" | yes |  |
| `language` | string: "en" \| "it" \| "es" \| "fr" \| "de" \| "pt" \| "nl" | yes |  |
| `key_locations` | array of object | yes | In the order to show them. |
| `areas` | array of object | yes | The areas `POST /frames/check?space_type=` can answer. Empty for an appliance. |

### CaptureProfileList

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string: "list" | yes |  |
| `data` | array of CaptureProfile | yes |  |

### SpaceCoverageCheck

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kind` | KeyLocationKind | yes |  |
| `seen` | boolean | yes | At least one frame shows it (probability from 0.7). |
| `probability` | number (≥ 0, ≤ 1) | yes | The highest probability over the frames. |
| `evidence` | array of SpaceEvidenceRef | yes |  |

### DeviceStartRequest

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string (max 60) | no | What is asking, shown to the person: "CLI on MacBook". Default "CLI". |
| `mode` | string: "live" \| "test" | no | The mode of the key asked for (the person can change it). Default live. |

### DeviceStart

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `device_code` | string | yes | The terminal's secret: never shown to the person. |
| `user_code` | string | yes |  |
| `verification_url` | string (uri) | yes | The console page where the person types the code. |
| `expires_in` | integer | yes |  |
| `interval` | integer | yes | Seconds between polls. |

### DeviceTokenRequest

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `device_code` | string | yes |  |

### DeviceToken

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `api_key` | string | yes | The new key: shown once, store it. |
| `key` | object | yes |  |
| `user` | ConsoleUser | yes |  |

### AgentCreditRequest

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `amount_cents` | integer (≥ 100, ≤ 50000) | no | US cents to pay (and, on mainnet, to credit): US$1.00 is one live scan. x402 clients cap a payment at US$1 by default; raise their limit to pay more at once |
| `rotate_key` | boolean | no | Make a new key and revoke the current one. Needs `Authorization: Bearer <the current key>` |

### AgentCredit

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | string: "agent_credit" | yes |  |
| `network` | string: "eip155:8453" \| "eip155:84532" \| "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" \| "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" | yes |  |
| `payer` | string (pattern ^(0x[0-9a-f]{40}\|[1-9A-HJ-NP-Za-km-z]{32,44})$) | yes | Lowercase on Base; base58 as written on Solana |
| `transaction` | string (pattern ^(0x[0-9a-f]{64}\|[1-9A-HJ-NP-Za-km-z]{64,88})$) | yes | The transaction hash (Base) or signature (Solana) |
| `livemode` | boolean | yes | true on mainnet: live credit and a live key |
| `amount_cents` | integer | yes |  |
| `balance_cents` | integer \| null | yes | The wallet after this payment; null on testnet (nothing is credited) |
| `api_key` | string (pattern ^sc_(live\|test)_) | no | Only when the key was created by this call: store it, it is not shown again |
| `api_key_prefix` | string \| null | yes | The account's key of this mode; null when it has none |
| `notice` | string | yes | What to do with the key (store it now, or use the one stored) |
| `docs` | string (uri) | yes |  |

### X402PaymentRequirements

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `scheme` | string: "exact" | yes |  |
| `network` | string: "eip155:8453" \| "eip155:84532" \| "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" \| "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" | yes | CAIP-2: Base (`eip155:…`) or Solana (`solana:…`) |
| `amount` | string (pattern ^\d+$) | yes | Atomic units of the asset (USDC: 6 decimals) |
| `asset` | string (pattern ^(0x[0-9a-fA-F]{40}\|[1-9A-HJ-NP-Za-km-z]{32,44})$) | yes | The USDC contract (Base) or mint (Solana) |
| `payTo` | string (pattern ^(0x[0-9a-fA-F]{40}\|[1-9A-HJ-NP-Za-km-z]{32,44})$) | yes | Who is paid: on Solana, the owner of the USDC account the transfer goes to |
| `maxTimeoutSeconds` | integer | yes |  |
| `extra` | object \| object | yes |  |

### X402PaymentRequired

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `x402Version` | integer: "2" | yes |  |
| `error` | string | no |  |
| `resource` | object | yes |  |
| `accepts` | array of X402PaymentRequirements | yes |  |
| `extensions` | object | no | `bazaar`: how to call this resource and what it returns, for x402 catalogs |

### AgentPaymentError

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | yes |  |

### Error

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | yes |  |
