# Sundial Agent Docs

Sundial workspaces expose an HTTP contract designed for coding agents
(Claude Code, Codex, Cursor, plain shell). No SDK, no client install. Every
operation is a single bearer-token-authenticated request.

This file is the source of truth for what an agent can do. It lives at
`/agent-docs` and is also linked from `/.well-known/agent.json`.

## I Just Received A Sundial Link

If a human handed you a URL like:

  https://www.sundial.md/w/<slug>?token=<token>

That `?token=` is your bearer credential. Treat it as a secret.

Call the canonical host (`www.sundial.md`). The bare apex redirects, and curl
drops `Authorization` and `Cookie` headers across a redirect, so a request to
the wrong host comes back as an unexplained 401.

Connecting is ONE call. `GET /files` upserts your presence server-side (the
human sees your chip) and returns the tree in the same response. No separate
heartbeat needed to join:

```bash
APP=https://www.sundial.md
TOKEN='<token>'
WS='<workspace-uuid>'   # workspaceId: see "Resolving slug → workspaceId" below
AGENT='ai:<your-agent-name>'

# Connect: registers presence AND lists files in one round trip.
curl -sS "$APP/api/workspace/local-agent/files?workspaceId=$WS" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT"
```

Reply `"Connected in Sundial and ready"` and report the files, then await
instructions. Read a file when you need one:

```bash
curl -sS "$APP/api/workspace/local-agent/file?workspaceId=$WS&path=README.md" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT"
```

## I Have No Workspace Yet

You do not need one, and you do not need an account. Mint an anonymous identity
and create a workspace from a template. Whoever holds the `sd_anon` value owns
it, and a human claims it permanently by signing in from that browser.

```bash
APP=https://www.sundial.md
ANON=$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom | head -c 16)

# 1. Create it. GET $APP/api/templates lists every slug.
curl -sS -X POST "$APP/api/templates/new" \
  -H 'Content-Type: application/json' -H "Cookie: sd_anon=$ANON" \
  -d '{"slug":"iclr-2026"}'
# → {"project":{"id":"<uuid>","open_url":"/w/<public-id>?fresh=1&anon=$ANON", ...}}

# 2. Mint a 7-day write token for it, same cookie.
curl -sS -X POST "$APP/api/workspace/local-agent/join" \
  -H 'Content-Type: application/json' -H "Cookie: sd_anon=$ANON" \
  -d '{"projectId":"<uuid from step 1>"}'
# → {"token":"...","workspaceUrl":"...","canWrite":true,"expiresAt":"..."}
```

Then continue with that token exactly as above. Hand the human `$APP` +
`open_url`, exactly as returned: that page is the live workspace and needs no
login to open. The `anon=` in the URL is the ownership handoff: opening it
moves the workspace identity into the human's browser, so signing in claims
the workspace for their account. Keep it on the link you hand over.

`{"slugs":["icml-2026","goal-loop"],"layout":"main"}` combines up to 4
templates. `{"editMode":"suggest"}` on the join call locks your writes to
reviewable suggestions for the whole session.

## Auth

- Preferred: `Authorization: Bearer <token>` on every request.
- If your tool runs each command in a fresh shell (Claude Code, Codex, etc.),
  environment variables do **not** persist between commands. Set the token on the
  same line as each request (`export TOKEN="<token>" && curl …`) rather than
  relying on an `export` from a previous command.
- Also accepted: `?token=<token>` on the workspace URL (for first-touch
  discovery only, then switch to the header for subsequent calls).
- Always send `X-Agent-Id: ai:<your-agent-name>`. This is what shows up in the
  provenance gutter and the presence chip.

## Endpoints

All paths are under `$APP/api/workspace/local-agent/`.

### `POST /presence` · Heartbeat

Keeps the presence chip alive. Any authenticated request already counts, so
you rarely need this. Only re-ping if you sit idle for several minutes.

```bash
curl -sS -X POST "$APP/api/workspace/local-agent/presence" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" \
  -d "{\"workspaceId\":\"$WS\",\"name\":\"Optional display name\"}"
```

Response: `{ "ok": true, "agentId": "...", "expiresInSeconds": 600 }`.

The chip stays alive ~10min after the last request, and **every** authenticated
agent request (files, file, collab-session) also bumps it. You only need an
explicit `/presence` call if you have nothing else to do.

### `GET /files` · List workspace files

```bash
curl -sS "$APP/api/workspace/local-agent/files?workspaceId=$WS" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT"
```

Response: `{ "ok": true, "files": [{ "id", "path", "type", "mime", "size", "updated_at" }], "canWrite": bool }`.

Files in runtime-reserved paths (logs, system folders) are filtered out.

Scope the listing instead of pulling the whole tree (both are server-side, no
sandbox):

- `&glob=<pattern>`: `*` matches within a path segment, `**` spans
  directories, `?` is one char. e.g. `**/*.ts`, `src/**`, `*.md`.
- `&path=<dir>`: flat list of everything under a subtree.

```bash
curl -sS "$APP/api/workspace/local-agent/files?workspaceId=$WS&glob=src/**/*.ts" \
  -H "Authorization: Bearer $TOKEN" -H "X-Agent-Id: $AGENT"
```

### `GET /grep` · Search file contents by regex

```bash
# URL-encode the regex; e.g. "function\s+\w+" → function%5Cs%2B%5Cw%2B
curl -sS "$APP/api/workspace/local-agent/grep?workspaceId=$WS&pattern=function%5Cs%2B%5Cw%2B" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT"
```

Runs server-side in Postgres over the materialized text mirror: index-backed,
always current, **no sandbox**. Mirrors Sunny's `grep` tool. Params: `pattern`
(POSIX ARE; `\b`/`\B` word boundaries are accepted and rewritten), optional
`path=<dir>` to scope a subtree, `i=1` for case-insensitive, `maxMatches`
(default 200, max 1000).

Response: `{ "ok": true, "matches": [{ "path", "line", "text" }], "overflow": bool, "output": "path:line: text\n…" }`.
`output` is the ready-to-show `path:line: text` rendering; `overflow` is true
when more matches existed than were returned (narrow the pattern or pass `path`).
A bad regex returns `400` (`code: "BAD_PATTERN"`) with the Postgres error. Fix
the pattern and retry. A rare pattern over a very large workspace scans every
file and can exceed the DB statement timeout: that returns `504`
(`code: "GREP_TIMEOUT"`, `retriable: true`). Scope it with `path=<dir>` or a
more specific pattern. For big repos, prefer scoping searches with `path`.

### `GET /file` · Read one file (with optional line range)

```bash
curl -sS "$APP/api/workspace/local-agent/file?workspaceId=$WS&path=docs/plan.md" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT"
```

Response: `{ "ok": true, "exists": bool, "path": "...", "content": "<text>", "updatedAt": "..." }`.

For long files, request a slice with `offset` (1-indexed line number) and
`limit` (max lines, default 2000), matching Sunny's `read_file` tool:

```bash
curl -sS "$APP/api/workspace/local-agent/file?workspaceId=$WS&path=long.md&offset=100&limit=50" \
  -H "Authorization: Bearer $TOKEN" -H "X-Agent-Id: $AGENT"
```

When a slice is requested, `content` is `cat -n`-style: each line prefixed
with `<line-number>\t`. The response also includes `sliced: true` and
`totalLines`. Send the slice anchored text to `POST /file/edit`'s
`old_string` (drop the line-number prefix, and never include it in
`old_string`).

Only text files return `content`. Binary files return `{ exists: true, path }`
without content. Fetch them through the web UI for now.

### `PUT /file` · Write a file (full text replace)

```bash
curl -sS -X PUT "$APP/api/workspace/local-agent/file" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" \
  -d "{\"workspaceId\":\"$WS\",\"path\":\"docs/plan.md\",\"content\":\"# New plan\\n\\n...\",\"baseUpdatedAt\":\"2026-05-17T08:30:46.419664+00:00\"}"
```

Writes are atomic at the file level: the full new text is stored, a row is
appended to the edit history with `actor=local_agent` and
`author_id=<X-Agent-Id>`, and the live CRDT doc is synced via Hocuspocus
`replaceDocumentText`. Connected humans see the change immediately with your
agent name in the provenance gutter.

**Optional `baseUpdatedAt`**: pass the `updatedAt` timestamp you got from
the last `GET /file` for this path. If the file has been modified since
then (human typed in the editor, another agent wrote it), the route returns
`409 STALE_BASE` with the current `updatedAt` and a `retryWithState` URL.
Re-read and resolve the conflict on your side before retrying. Omit the
field for fire-and-forget writes.

Read-only tokens get `403`. Paths reserved for runtime artifacts get `404`.

**`editMode` · suggest by default**: writes on `PUT /file` and `POST /file/edit`
land as a pending suggestion (humans see a reviewable diff in the editor and
accept or reject it) unless the human explicitly tells you to apply changes
directly. When they do, add `"editMode":"edit"` to the body to write the document
directly. Humans can also lock your whole connection to suggest ("Suggest only"
on your agent chip, or a suggest-only token): every write then lands as a
suggestion and delete, rename, exec, and uploads return 403. The response echoes
the effective `editMode`, and a suggest write also returns the staged
`suggestionId`. Keep it: the accept/reject decision arrives on `GET /events`
keyed by that id.

**Reads show the accepted projection**: `GET /file` (and `GET /grep`) return the
document *as if every pending suggestion were accepted*, your own and other
authors' alike. Pending text is not distinguishable in the content, and a
rejected suggestion's text disappears from later reads. Before anchoring an edit
on text that might be someone's unreviewed proposal, check `GET /suggestions`.

### `DELETE /file` · Delete a file or folder

```bash
curl -sS -X DELETE "$APP/api/workspace/local-agent/file" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" \
  -d "{\"workspaceId\":\"$WS\",\"path\":\"old-notes.md\"}"
```

Deletes the file, or the whole subtree when `path` is a folder, including
any uploaded blobs. Response: `{ "ok": true, "deleted": ["old-notes.md"] }`.
Deleting a missing path is an idempotent `ok` with `deleted: []`. A
`file.deleted` activity event is logged per file with your agent id.

### `PATCH /file` · Move / rename a file or folder

```bash
curl -sS -X PATCH "$APP/api/workspace/local-agent/file" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" \
  -d "{\"workspaceId\":\"$WS\",\"sourcePath\":\"draft.md\",\"targetPath\":\"docs/final.md\"}"
```

Moves a file or a folder subtree. Same semantics as the web UI's rename:
`404` unknown source, `409` when the target already exists, `400` when
moving a folder into itself. Response lists the updated `{id, path}` pairs.

### `POST /exec` · Run bash in a per-agent Modal sandbox

```bash
curl -sS -X POST "$APP/api/workspace/local-agent/exec" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" \
  -d "{\"workspaceId\":\"$WS\",\"command\":\"ls -la && python -V\",\"timeoutSeconds\":30}"
```

Response: `{ "ok": true, "output": "...", "exit_code": 0, "sandboxKey": "agent:ai:claude-code", "sandboxReused": true, ... }`.

Use `/exec` for *running code* (tests, builds, scripts). For listing and
searching, prefer `GET /files` (with `glob`/`path`) and `GET /grep`. They hit
Postgres directly and skip the sandbox boot entirely.

The sandbox is keyed on `(workspace_id, agent_id)` and reused across calls
within the same agent. Workspace files are hydrated automatically. Any file
changes inside the sandbox flow through the file watcher and land in
`doc_edits` with `actor=local_agent`, `author_id=<X-Agent-Id>`, identical
provenance to `PUT /file`. Idle containers are reaped after ~15min.

`/exec` follows the same edit mode as the write endpoints: by default the file
creates and edits your command produces land as pending suggestions; pass
`"editMode":"edit"` in the body to apply them directly. Deletes and renames
always apply directly, since they can't be staged as reviewable suggestions.
(Explicit suggest-only agents, by suggest-only token or the chip switch, can't
`/exec` at all and get `403`.) The response echoes the effective `editMode`.

Timeout range: 1–300 seconds (default 30). Read-only tokens get `403`.

### `POST /file/edit` · Surgical find/replace edits

For long files, send only the diff instead of the whole content. Matches
the shape of Sunny's `edit` / `multiedit` tools (and Claude Code's
`Edit` / `MultiEdit`), so you can pass your native tool input straight
through. Always batch multiple edits to one file into a **single** call's
`edits` array. One request per edit is far slower.

```bash
curl -sS -X POST "$APP/api/workspace/local-agent/file/edit" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" \
  -d "{
    \"workspaceId\":\"$WS\",
    \"path\":\"docs/plan.md\",
    \"edits\":[
      {\"old_string\":\"draft\",\"new_string\":\"final\"},
      {\"old_string\":\"TODO\",\"new_string\":\"DONE\",\"replace_all\":true}
    ]
  }"
```

Edits apply sequentially against the in-memory text and the new full
content is persisted via the same `record_text_edit` RPC as `PUT /file`,
so attribution and live editor sync are identical.

| Error | Status | Meaning |
|---|---|---|
| `ANCHOR_NOT_FOUND` | 409 | `old_string` did not match anywhere. Response includes `editIndex`. |
| `AMBIGUOUS_ANCHOR` | 409 | `old_string` matched multiple times and `replace_all` was false. Expand the anchor or set `replace_all: true`. |
| `STALE_BASE` | 409 | File moved since `baseUpdatedAt` (if supplied). Re-read and retry. |

Accepts both `old_string`/`new_string`/`replace_all` (snake_case) and
`oldString`/`newString`/`replaceAll` (camelCase). Up to 100 edits per call.

### `POST /comments` · Anchor a comment to a quoted span

```bash
curl -sS -X POST "$APP/api/workspace/local-agent/comments" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" \
  -d "{
    \"workspaceId\":\"$WS\",
    \"path\":\"docs/plan.md\",
    \"quote\":\"The rewrite ships tomorrow.\",
    \"body\":\"Are you sure about Tuesday? CI is red.\"
  }"
```

Threads land in the same `doc_comment_threads` table as human comments
and show up in the workspace comments panel. Returns `409 ANCHOR_NOT_FOUND`
when `quote` doesn't appear verbatim in the current file. Re-read via
`GET /file` and try again.

Reply: `POST /comments/<threadId>/messages` with `{workspaceId, body}`.
Resolve: `POST /comments/<threadId>/resolve` with `{workspaceId}` (add
`?action=reopen` to reopen). List: `GET /comments?workspaceId=&path=`.

### `GET /events` · Long-poll for review feedback

```bash
curl -sS "$APP/api/workspace/local-agent/events?workspaceId=$WS&path=<p>&since=<ISO>" \
  -H "Authorization: Bearer $TOKEN" -H "X-Agent-Id: $AGENT"
```

Blocks up to ~55s and returns review feedback as it lands, so after you make
edits you can *watch* instead of being re-prompted. Two event types:

- `{type:'comment', threadId, path, quote, body, author, createdAt}`: the
  user commented. Read it, reply via `POST /comments/<threadId>/messages`,
  revise the file, and watch again.
- `{type:'suggestion', decision:'accepted'|'rejected'|'mixed'|'unresolved',
  suggestionId, path, reason?, actor, createdAt}`: the user decided one of
  YOUR pending suggestions (`suggestionId` matches the id your suggest write
  returned). On `rejected`, don't re-apply the same edit; revise per `reason`
  or ask in a comment thread.

`since` defaults to "now"; pass the returned `cursor` as the next `since` to
page forward. Your own comments and decisions are filtered out. While blocked
the response streams whitespace heartbeats (a valid JSON prefix, so `JSON.parse`
of the full body still works). Empty `events` just means the wait elapsed.
Watch again.

If your harness can run commands in the background (Claude Code's `run_in_background`),
run this curl as a background task and end your turn instead of blocking on it: the human
keeps chatting, and the harness wakes you with the command's output the moment a comment
lands. Handle it, restart the background watch with the returned `cursor` as `since`, and
end your turn again. Harnesses without background wake-ups just make the blocking call.

### `GET /suggestions` · Pending-suggestion state

```bash
curl -sS "$APP/api/workspace/local-agent/suggestions?workspaceId=$WS&path=<p>" \
  -H "Authorization: Bearer $TOKEN" -H "X-Agent-Id: $AGENT"
```

Lists the workspace's suggestions with live status:
`{suggestions:[{suggestionId, path, status, authorId, actor, createdAt}]}`.
`status` is `pending` by default; add `includeResolved=1` to also see
`accepted` / `rejected` / `mixed`. Because file reads show the accepted
projection (above), this is how you tell which spans of a file are still
someone's unreviewed proposal before you anchor an edit on them. `path` is
optional and scopes to one file.

### `GET /collab-session` · Optional Hocuspocus WS ticket

```bash
curl -sS "$APP/api/workspace/local-agent/collab-session?workspaceId=$WS" \
  -H "Authorization: Bearer $TOKEN" -H "X-Agent-Id: $AGENT"
```

Response: `CollabSessionInfo` (matches EveryInc/proof-sdk):

```json
{
  "success": true,
  "collabAvailable": true,
  "session": {
    "syncProtocol": "pm-yjs-v1",
    "collabWsUrl": "wss://hocuspocus.sundial.md",
    "token": "<same bearer>",
    "docNamePrefix": "<workspaceId>/",
    "awareness": { "user": { "name": "...", "color": "#..." }, "kind": "local-agent" }
  },
  "capabilities": { "canRead": true, "canComment": true, "canEdit": true }
}
```

Optional. The HTTP endpoints work without it. Holding the WS earns the
in-doc cursor. Document writes pushed over the socket follow the same edit
mode, baked in for the whole session: suggestions by default, or append
`&editMode=edit` to mint a direct-edit ticket. `session.editMode` echoes the
effective mode.

A ready-to-run Node helper is bundled at
`$APP/sundial-agent-ws.mjs`. Install once
(`npm install --prefix ~/.sundial @hocuspocus/provider yjs ws`), then run
`node ~/.sundial/sundial-agent-ws.mjs --session "$SESSION_JSON" --path docs/plan.md &`
for each file you're working in. The helper:

- Holds the WebSocket open while it runs
- Sets `awareness.user.{name, color}` so the cursor renders with your brand
- Re-pins the cursor to the end of the doc on every Yjs update
- Cleanly drops awareness on SIGINT/SIGTERM

When `NEXT_PUBLIC_COLLAB_WS_URL` isn't configured the response is
`{ "collabAvailable": false, "code": "COLLAB_NOT_CONFIGURED" }`, so fall
back to HTTP-only.

### Large / binary files · the upload rail

`PUT /file` / `POST /file/edit` carry content as JSON through the collaborative
Y.Doc and cap at ~5 MB (a `PUT` over that returns `413` with `useUpload: true`).
Larger or binary files (datasets, PDFs, images) go through the resumable TUS
rail: `precheck` (dedup by sha), stream, `finalize`:

```bash
SHA=$( { sha256sum <file> 2>/dev/null || shasum -a 256 <file>; } | cut -d' ' -f1)
curl -sS -X POST "$APP/api/workspace/uploads/precheck" \
  -H "Authorization: Bearer $TOKEN" -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" -d "{\"projectId\":\"$WS\",\"sha\":\"$SHA\"}"
```

Stream the bytes with a real TUS client. **Do not raw-PATCH with curl**:
Supabase's resumable endpoint needs fixed 6 MB chunks and 500s otherwise.
Install `tus-js-client` and use `chunkSize: 6*1024*1024`:

```bash
SHA="$SHA" node --input-type=module -e '
  import {Upload} from "tus-js-client"; import fs from "node:fs";
  const f="<file>";
  new Upload(fs.createReadStream(f), {
    endpoint:"'"$APP"'/api/workspace/uploads/tus", chunkSize:6*1024*1024,
    uploadSize:fs.statSync(f).size,
    metadata:{projectId:"'"$WS"'", sha:process.env.SHA, contentType:"<mime>"},
    headers:{Authorization:"Bearer '"$TOKEN"'", "X-Agent-Id":"'"$AGENT"'"},
    onError:e=>{console.error(e);process.exit(1)}, onSuccess:()=>console.log("uploaded"),
  }).start();'
curl -sS -X POST "$APP/api/workspace/uploads/finalize" \
  -H "Authorization: Bearer $TOKEN" -H "X-Agent-Id: $AGENT" \
  -H "Content-Type: application/json" \
  -d "{\"projectId\":\"$WS\",\"path\":\"<path>\",\"sha\":\"$SHA\",\"mime\":\"<mime>\"}"
```

## Formatting markdown files

Markdown files are parsed through Sundial's editor codec on every write, so
plain markdown syntax becomes real formatting, the same result a human gets
from the toolbar. Just write the syntax in `content` / `new_string`; there is
no separate formatting API.

- marks: `**bold**`, `*italic*`, `~~strike~~`, `` `code` ``, `==highlight==`
- link: `[text](url)`, a web URL **or** a relative workspace path with an
  optional `#heading` anchor, e.g. `[scope](docs/plan.md#scope)`
- wiki link / embed: `[[target|alias]]` / `![[target|alias]]`
- headings `#`…`######`; lists `-` / `1.`; tasks `- [ ]` / `- [x]`; blockquote
  `>`; callout `> [!NOTE]` (foldable `[!NOTE+]` / `[!NOTE-]`)
- code block ` ```lang `; GFM pipe table; image `![alt](src)`; rule `---`;
  math `$inline$` and `$$block$$`

No markdown exists for text color, highlight *color* (`==x==` is always the
default), underline, font family/size, or text alignment. Those are
toolbar-only and can't be set through this API.

## Resolving slug → workspaceId

The URL slug (`/w/<slug>`) and the `workspaceId` UUID are both valid
references but only the UUID is accepted by the REST endpoints. The bootstrap
prompt from the workspace UI embeds the UUID directly. If you only have the
slug, ask the human for the UUID or call the join endpoint from a
browser-authenticated context.

## Attribution Model

Every edit is recorded with:

- `actor`: `local_agent` for any write through `PUT /file`.
- `author_id`: the `X-Agent-Id` header value (defaults to `ai:local-agent`).

These show up in the provenance gutter and the diff timeline. Use a stable,
human-readable agent id (`ai:claude-code`, `ai:codex`, `ai:cursor`) so a
human can tell two agents apart at a glance.

## Errors

| Status | Meaning | Action |
|---|---|---|
| `400` | Missing required field | Fix the request body. |
| `401` | Bad/missing bearer token | Re-read the token from the workspace URL. |
| `403` | Token is read-only | You can read and presence-ping but not write. |
| `404` | Path reserved or file missing | For reads, treat as `{exists: false}`. |
| `500` | Server error during write | Retry once; if it persists, capture the response and stop. |

## Discovery

- Start here: `$APP/start` (the skill: install it, create a workspace, work)
- Manifest: `$APP/.well-known/agent.json`
- This file: `$APP/agent-docs`
- Templates: `$APP/api/templates`, or `$APP/t/<slug>` for a scoped start guide

## What's not here yet

Sundial does not currently expose block-level edits (we have string-level
find/replace, not stable block IDs). For the typed mutation grammar
discussion, see `docs/local-agent.md` in the repo. Agent-authored comments
(`POST /comments`) and event watching (`GET /events`) are both shipped.
See the sections above.

Optimistic locking IS available: pass `baseUpdatedAt` on `PUT /file` or
`POST /file/edit`.
