@figs-so/cli 0.8.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -39
- package/SPEC.md +214 -173
- package/figs.mjs +1085 -579
- package/package.json +1 -1
package/SPEC.md
CHANGED
|
@@ -1,66 +1,78 @@
|
|
|
1
|
-
# The `.figs` Protocol — `figs-spec
|
|
1
|
+
# The `.figs` Protocol — `figs-spec v2`
|
|
2
2
|
|
|
3
|
-
> **Status:**
|
|
4
|
-
>
|
|
5
|
-
>
|
|
6
|
-
>
|
|
3
|
+
> **Status:** v2. This spec defines the `.figs/` folder an AI agent writes, how it is published, and
|
|
4
|
+
> how a human's replies come back. It is deliberately small. **Account-optional:** the protocol and the
|
|
5
|
+
> local tooling are fully usable with no account and no network; a reader/remote is strictly additive.
|
|
6
|
+
> Licensed **MIT** — implement it in anything.
|
|
7
|
+
>
|
|
8
|
+
> *v2 (from v1): the human-reply ledger (`messages.jsonl`) is now part of the format and the loop is
|
|
9
|
+
> two-way; ask types narrowed to `question`/`sign-off`; one unified `attachments[]`; `config.json`'s
|
|
10
|
+
> destination fields are optional (local mode); wire auth is `Authorization: Bearer`. See each section.*
|
|
7
11
|
|
|
8
12
|
## 1. Design principles
|
|
9
13
|
|
|
10
|
-
- **
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
- **Local-first, account-optional.** The agent owns a `.figs/` folder on disk and is fully operational
|
|
15
|
+
with no account and no network — record work, raise asks, recover across sessions, validate. Publishing
|
|
16
|
+
to a reader is an explicit, optional act (`push`), not a live connection.
|
|
17
|
+
- **Agent ledgers flow one way (up); the human ledger is the one two-way file.** An agent *publishes*
|
|
18
|
+
its own records (`runs.jsonl`, `asks.jsonl`); a reader never writes them back. A human's replies live
|
|
19
|
+
in `messages.jsonl` — the single file that also syncs *down* (§6, §8). Nobody writes into the other
|
|
20
|
+
side's ledger.
|
|
21
|
+
- **One agent = one repo = one machine** (the topology rule). The agent ledgers are the source of truth
|
|
22
|
+
on that machine; a reader is an aggregation mirror for humans, never an authority over the files.
|
|
23
|
+
Running one agent from several machines at once is unsupported (commit the outbox and manage the merge
|
|
24
|
+
yourself, at your own risk); the fleet-wide cross-machine view is the reader's job.
|
|
25
|
+
- **Upsert-only, never destructive.** Publishing inserts or updates records by their `id`; it never
|
|
26
|
+
deletes. A push may not walk a record backwards (a stale close/settle never reopens — §8).
|
|
27
|
+
- **Two content modes, no display language.** Everything is either *structured state* (the JSON/JSONL
|
|
28
|
+
below, rendered by fixed components) or an *attachment* (a file shown in a sandboxed viewer or offered
|
|
29
|
+
for download). There is no layout/templating DSL.
|
|
30
|
+
- **Self-describing identity.** An agent generates its own UUID once; that UUID *is* its identity. The
|
|
31
|
+
same agent (a repo) may be run by many people; their pushes aggregate under that one identity.
|
|
21
32
|
|
|
22
33
|
## 2. Folder layout
|
|
23
34
|
|
|
24
35
|
```
|
|
25
36
|
.figs/
|
|
26
|
-
├── config.json # identity + destination
|
|
37
|
+
├── config.json # identity (+ destination once linked); committed, non-secret
|
|
27
38
|
├── agent.json # the charter — who this agent is (committed)
|
|
28
39
|
├── CONTRACT.md # agent-authored: what this agent surfaces / holds back (committed)
|
|
29
40
|
├── GUIDE.md # orientation breadcrumb, written by the CLI (committed)
|
|
30
|
-
├── runs.jsonl # activity log
|
|
31
|
-
├── asks.jsonl #
|
|
32
|
-
|
|
41
|
+
├── runs.jsonl # activity log — one job per line (machine-local outbox; gitignored)
|
|
42
|
+
├── asks.jsonl # handoffs to a human — one ask per line (machine-local outbox; gitignored)
|
|
43
|
+
├── messages.jsonl # the human's replies — one event per line (machine-local; gitignored)
|
|
44
|
+
└── artifacts/ # files attached to any moment (machine-local; gitignored)
|
|
33
45
|
```
|
|
34
46
|
|
|
35
|
-
**Commit** `config.json` + `agent.json` + `CONTRACT.md` + `GUIDE.md`. The
|
|
36
|
-
(`runs.jsonl`, `asks.jsonl`, `artifacts/`)
|
|
47
|
+
**Commit** `config.json` + `agent.json` + `CONTRACT.md` + `GUIDE.md`. The journal
|
|
48
|
+
(`runs.jsonl`, `asks.jsonl`, `messages.jsonl`, `artifacts/`) is a **machine-local** outbox — records
|
|
49
|
+
live on this machine; once linked + pushed, the reader is the durable record humans see.
|
|
37
50
|
|
|
38
|
-
**`CONTRACT.md` + `GUIDE.md` are companion conventions, not wire format** —
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
**`CONTRACT.md` + `GUIDE.md` are companion conventions, not wire format** — never pushed.
|
|
52
|
+
`CONTRACT.md` is the standing agreement between agent and user about what gets surfaced; `GUIDE.md`
|
|
53
|
+
is an orientation stub the reference CLI writes (and never clobbers). Implementations may add files
|
|
54
|
+
like these; **readers must ignore files this spec doesn't name.**
|
|
42
55
|
|
|
43
|
-
**The membership rule
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
memory, self-checks, scratch notes — lives outside `.figs/`, elsewhere in the repo. If a file's
|
|
47
|
-
only reader is the agent itself, it does not belong here.
|
|
56
|
+
**The membership rule:** everything in `.figs/` is *Figs-facing* — protocol metadata, the published
|
|
57
|
+
record, or a convention *about* publishing. An agent's private working state (memory, scratch notes)
|
|
58
|
+
lives elsewhere in the repo. If a file's only reader is the agent itself, it does not belong here.
|
|
48
59
|
|
|
49
|
-
## 3. `config.json` — identity + destination
|
|
60
|
+
## 3. `config.json` — identity (+ destination)
|
|
50
61
|
|
|
51
|
-
Non-secret.
|
|
62
|
+
Non-secret. In **local mode** it is just `{ "agentId": "…" }`. `figs link` adds the destination
|
|
63
|
+
(`endpoint` + `workspaceId`) when the agent connects to a reader.
|
|
52
64
|
|
|
53
|
-
| Field | Type | Notes |
|
|
54
|
-
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
65
|
+
| Field | Type | Req | Notes |
|
|
66
|
+
|---|---|:--:|---|
|
|
67
|
+
| `agentId` | UUID | ✓ | The agent's identity, minted once by `figs init`. The CLI attaches it as the agent's `id` on push (you don't hand-author `id` in `agent.json`). |
|
|
68
|
+
| `endpoint` | string (URL) | | Where to publish (default `https://app.figs.so`). Written by `figs link`. |
|
|
69
|
+
| `workspaceId` | UUID | | The workspace this agent belongs to. Written by `figs link`. **Its presence is what "linked" means** — absent = local mode. |
|
|
58
70
|
|
|
59
71
|
## 4. `agent.json` — the charter
|
|
60
72
|
|
|
61
73
|
The agent's self-description. Authoring this and publishing makes the agent *appear*. The only field you
|
|
62
|
-
author that's required is `name` — **do not hand-author `id`**: `figs init` mints it into `config.json`
|
|
63
|
-
the CLI attaches it on push. Everything else is optional and rendered when present.
|
|
74
|
+
author that's required is `name` — **do not hand-author `id`**: `figs init` mints it into `config.json`
|
|
75
|
+
and the CLI attaches it on push. Everything else is optional and rendered when present.
|
|
64
76
|
|
|
65
77
|
| Field | Type | Req | Meaning |
|
|
66
78
|
|---|---|:--:|---|
|
|
@@ -76,7 +88,7 @@ the CLI attaches it on push. Everything else is optional and rendered when prese
|
|
|
76
88
|
| `mandate` | string | | One-paragraph statement of what it's responsible for. |
|
|
77
89
|
| `steps` | string[] | | **Ordered** procedure (numbered render). For pipeline-shaped agents. |
|
|
78
90
|
| `responsibilities` | string[] | | **Unordered** areas of work (bulleted render). For broad/mission agents. |
|
|
79
|
-
| `properties` | `{ k, v }[]` | | Freeform catch-all for facts with no dedicated field. Keep keys short, values single-line.
|
|
91
|
+
| `properties` | `{ k, v }[]` | | Freeform catch-all for facts with no dedicated field. Keep keys short, values single-line. |
|
|
80
92
|
| `units` | `Unit[]` | | The instances/things the agent operates on (see below). |
|
|
81
93
|
|
|
82
94
|
Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipeline vs. a set of work areas.
|
|
@@ -95,186 +107,216 @@ Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipel
|
|
|
95
107
|
|
|
96
108
|
## 5. `runs.jsonl` — activity
|
|
97
109
|
|
|
98
|
-
One JSON object per line (JSON Lines). **One record = one job** — a unit of work the agent's
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
instead of nothing. A **report** files the outcome and settles it; a report with no prior
|
|
109
|
-
checkpoint is simply a job **born settled** (the single-sitting case). Nothing *external*
|
|
110
|
+
One JSON object per line (JSON Lines). **One record = one job** — a unit of work the agent's *manager*
|
|
111
|
+
would recognize ("recon — Acme — November"), under a **stable, meaningful id** (`recon-acme-2026-11`);
|
|
112
|
+
the runs list reads as the job list. Records **fold by `id`**: re-reporting a job's id layers progress
|
|
113
|
+
onto its row (`status` evolves `warn` → `ok`) — sittings/sessions are agent plumbing and never mint
|
|
114
|
+
records. Closing an ask is **not** a job: that's a `resolution` in `asks.jsonl` (§6), never a run.
|
|
115
|
+
|
|
116
|
+
A job is either **in flight** or **settled** (`state`). A **checkpoint** (`figs checkpoint`) folds
|
|
117
|
+
progress onto the job's id and marks it in-flight — the record survives the session working it, so a
|
|
118
|
+
crash mid-job leaves a visible, recoverable stub. A **report** files the outcome and settles it; a
|
|
119
|
+
report with no prior checkpoint is a job **born settled** (the single-sitting case). Nothing *external*
|
|
110
120
|
ever closes a run — only the agent's own report settles its job.
|
|
111
121
|
|
|
112
122
|
| Field | Type | Req | Meaning |
|
|
113
123
|
|---|---|:--:|---|
|
|
114
124
|
| `id` | string | ✓ | Stable id (upsert key). |
|
|
115
|
-
| `ts` | string (ISO-8601 w/ offset) | ✓ | When it ran,
|
|
125
|
+
| `ts` | string (ISO-8601 w/ offset) | ✓ | When it ran. Machine-stamped by the CLI, never typed. |
|
|
116
126
|
| `unit` | string | | The `Unit.id` this run is about. |
|
|
117
127
|
| `period` | string | | |
|
|
118
|
-
| `result` | string | | The job's current one-line state
|
|
119
|
-
| `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — what the work looks like
|
|
120
|
-
| `state` | `"in-flight"` \| `"settled"` | | Default `"settled"`. **Lifecycle, verb-stamped** — `
|
|
121
|
-
| `
|
|
122
|
-
| `session` | `Session` | | Where/how this ran (
|
|
128
|
+
| `result` | string | | The job's current one-line state while in flight; its outcome once settled. |
|
|
129
|
+
| `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** — what the work looks like now (a stuck job is `warn`); whether it's *done* is `state`. |
|
|
130
|
+
| `state` | `"in-flight"` \| `"settled"` | | Default `"settled"`. **Lifecycle, verb-stamped** — `checkpoint` → in-flight, `report` → settled. An in-flight job whose agent died stays in flight: the next session finds it in `figs inbox` and finishes or settles it. |
|
|
131
|
+
| `attachments` | string[] | | File names under `artifacts/` produced at this moment (§7). Attachments belong to their line, not the folded record. |
|
|
132
|
+
| `session` | `Session` | | Where/how this ran ([§5.1](#51-session--runtime-metadata-optional)). Optional, self-reported. |
|
|
123
133
|
|
|
124
134
|
### 5.1 `Session` — runtime metadata (optional)
|
|
125
135
|
|
|
126
136
|
An optional, **self-reported** block describing the runtime session that produced a run (or raised an
|
|
127
|
-
ask
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
Cryptographic provenance remains [reserved](#reserved-not-in-v1).
|
|
137
|
+
ask, or a message). Every field is optional. This is *transparency, not attestation*: the values come
|
|
138
|
+
from the runtime's own records — hand-authored, or written by integrations that copy provable values at
|
|
139
|
+
work-time (the CLI never infers them). Cryptographic provenance remains [reserved](#reserved-not-in-v2).
|
|
131
140
|
|
|
132
141
|
| Field | Type | Meaning |
|
|
133
142
|
|---|---|---|
|
|
134
|
-
| `runtime` | string | What ran it, e.g. `claude-code`, `codex
|
|
143
|
+
| `runtime` | string | What ran it, e.g. `claude-code`, `codex`. |
|
|
135
144
|
| `model` | string | Model id, e.g. `claude-fable-5`. |
|
|
136
145
|
| `sessionId` | string | The runtime's own session identifier. |
|
|
137
146
|
| `startedAt` | string (ISO-8601 w/ offset) | When this job began (the record's `ts` is when it was reported). |
|
|
138
|
-
| `commit` | string | The
|
|
139
|
-
| `trigger` | string | What set this sitting in motion — one self-reported line
|
|
140
|
-
| `tokens` | `{ input?, output?, cacheRead?, cacheWrite? }`
|
|
147
|
+
| `commit` | string | The repo's HEAD at run time; append `+dirty` when the tree had uncommitted changes. |
|
|
148
|
+
| `trigger` | string | What set this sitting in motion — one self-reported line (`monthly close cron`, `inbox: answer on acme-bridge`, `Wayne, in chat`). A *fresh* sitting states it; continuations omit it. |
|
|
149
|
+
| `tokens` | `{ input?, output?, cacheRead?, cacheWrite? }` | **Session totals at report time** — cumulative for the whole session, not per-job. Approximate by design. |
|
|
141
150
|
|
|
142
151
|
## 6. `asks.jsonl` — handoffs to a human
|
|
143
152
|
|
|
144
|
-
One JSON object per line. Each is something the agent needs a person to resolve
|
|
145
|
-
|
|
153
|
+
One JSON object per line. Each is something the agent needs a person to resolve — the agent reached the
|
|
154
|
+
edge of its autonomy.
|
|
146
155
|
|
|
147
156
|
| Field | Type | Req | Meaning |
|
|
148
157
|
|---|---|:--:|---|
|
|
149
158
|
| `id` | string | ✓ | Stable id (upsert key). |
|
|
150
|
-
| `type` | enum | ✓ | `
|
|
151
|
-
| `status` | enum | | `"open"` (default) \| `"resolved"` (the need was met) \| `"withdrawn"` (the **
|
|
152
|
-
| `to` | `"manager"` \| `"builder"` | | Who the ask is addressed to: the human accountable for the **work** (`manager`) or for the **machine** (`builder`
|
|
159
|
+
| `type` | enum | ✓ | `question` \| `sign-off` — **the type is the answer contract**: *question* wants an answer (an option or free text), *sign-off* wants a verdict (approve / request-changes / reject). (`needs-decision` was renamed `question`; `fyi` was retired — a for-the-record note is a settled report, not an ask; `blocked` is the run's `status`, not an ask type.) |
|
|
160
|
+
| `status` | enum | | `"open"` (default) \| `"resolved"` (the need was met) \| `"withdrawn"` (the **agent** retracted it; nobody acted) \| `"rejected"` (a human declined it). **Rejected is terminal** on this id — re-raising is a new ask. |
|
|
161
|
+
| `to` | `"manager"` \| `"builder"` | | Who the ask is addressed to: the human accountable for the **work** (`manager`) or for the **machine** (`builder`). Absent = unaddressed. |
|
|
153
162
|
| `title` | string | ✓ | The ask, in one line. |
|
|
154
163
|
| `unit` | string | | The `Unit.id` this concerns. |
|
|
155
|
-
| `run` | string | | The run `id` this ask was raised during
|
|
164
|
+
| `run` | string | | The run `id` this ask was raised during. **Optional** — asks also arise outside runs. |
|
|
156
165
|
| `found` | string | | What the agent found / why it's stuck. |
|
|
157
166
|
| `need` | string | | What it needs from the human. |
|
|
158
|
-
| `options` | string[] | | Candidate
|
|
159
|
-
| `onApprove` | string[] | | **Sign-off only.** The ordered steps approval sets in motion — **an approval authorizes exactly these
|
|
167
|
+
| `options` | string[] | | Candidate answers — **short, stable, quotable** strings: a reply cites one *verbatim* (§6.2). On a **sign-off** they are qualified-verdict paths (e.g. `"Approved — file the 15 ready charges"`). |
|
|
168
|
+
| `onApprove` | string[] | | **Sign-off only.** The ordered steps approval sets in motion — **an approval authorizes exactly these steps, in order**; flag anything irreversible in the step. The agent's declared intent, not a bound plan. Invalid on a `question`. |
|
|
160
169
|
| `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
|
|
161
|
-
| `
|
|
170
|
+
| `attachments` | string[] | | File names under `artifacts/` attached to this ask (the exact content to review — §7). |
|
|
162
171
|
| `resolution` | string \| `Resolution` | | The agent's account of the close ([§6.2](#62-resolution--how-an-ask-closed)). A bare string is shorthand for `{ "note": … }`. |
|
|
163
|
-
| `ts` | string (ISO-8601 w/ offset) | | |
|
|
164
|
-
| `session` | `Session` | | The session that raised this ask
|
|
165
|
-
|
|
166
|
-
### 6.1 Lifecycle — two ledgers, split by author
|
|
172
|
+
| `ts` | string (ISO-8601 w/ offset) | | Machine-stamped. |
|
|
173
|
+
| `session` | `Session` | | The session that raised this ask. |
|
|
167
174
|
|
|
168
|
-
|
|
175
|
+
### 6.1 Lifecycle — two ledgers, two directions
|
|
169
176
|
|
|
170
|
-
|
|
171
|
-
(field-level merge: later lines layer over earlier ones), so the close is an *append*, not an edit:
|
|
177
|
+
An ask anchors a thread whose two halves are owned by different parties:
|
|
172
178
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"by": "Sarah (accounting)", "ts": "2026-06-01T09:12:00Z" } }
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
Appending keeps the local file crash-safe, concurrency-safe (multiple runners), and an honest
|
|
179
|
+
- **The agent's ledger** is `asks.jsonl` — only the agent writes here, one-way **up**. Records **fold
|
|
180
|
+
by `id`** (field-level merge; later lines layer over earlier), so the close is an *append*, not an
|
|
181
|
+
edit. Appending keeps the file crash-safe, concurrency-safe (same-machine sessions), and an honest
|
|
180
182
|
self-audit trail; the folded record the reader stores is one complete ask.
|
|
181
|
-
- **The human's ledger** is
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
- **The human's ledger** is `messages.jsonl` (§6.3) — replies (answers/verdicts). It is the one file
|
|
184
|
+
that flows **down** too: a reply made in the reader's UI syncs into `messages.jsonl`; a reply given
|
|
185
|
+
out-of-band (e.g. in chat) is transcribed there by the agent (`figs answer`). Either way, nobody
|
|
186
|
+
writes the other side's ledger.
|
|
184
187
|
|
|
185
|
-
|
|
186
|
-
`resolved` | `withdrawn`
|
|
187
|
-
**`rejected`** (a reject verdict in the reader's UI closes the ask immediately; the agent's
|
|
188
|
-
later resolution append folds onto it without reopening). Today resolution otherwise happens
|
|
189
|
-
in the agent's own workflow; answers flowing back through the reader are arriving incrementally.
|
|
188
|
+
State machine: `open` → *(a reply arrives — `messages.jsonl`)* → the agent **closes** it
|
|
189
|
+
(`resolved` | `withdrawn` | `rejected`, derived from the reply; §6.2). `rejected` is terminal.
|
|
190
190
|
|
|
191
191
|
### 6.2 `Resolution` — how an ask closed
|
|
192
192
|
|
|
193
|
+
The close is derived from the newest reply on the ask and cites it.
|
|
194
|
+
|
|
193
195
|
| Field | Type | Meaning |
|
|
194
196
|
|---|---|---|
|
|
195
197
|
| `note` | string | The agent's one-line account of the close. |
|
|
196
|
-
| `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `answer` | string | The
|
|
200
|
-
| `ts` | string (ISO-8601 w/ offset) | When the agent closed it — **machine-stamped
|
|
198
|
+
| `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]` (copied from the cited reply). |
|
|
199
|
+
| `run` | string | The job the reply set in motion (mirror of `ask.run`) — so a reader can navigate answer → work → outcome. |
|
|
200
|
+
| `via` | `"figs"` \| `"human"` \| `"self"` | How it closed: `figs` = derived from a reply on file, citing it (`answer`) · `human` = an out-of-band reply with no event cited · `self` = the blocker cleared on its own. |
|
|
201
|
+
| `answer` | string | The `messages.jsonl` event id the close acted on — written by `figs close` (attribution by mechanism, never typed). **Trust derives from that event's mint origin** (§6.3), not from this field. |
|
|
202
|
+
| `ts` | string (ISO-8601 w/ offset) | When the agent closed it — **machine-stamped, never typed**. Lives *inside* `resolution` so the fold can't collide with the ask's raise `ts`. |
|
|
201
203
|
|
|
202
|
-
All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }
|
|
203
|
-
normalize it to the object form.
|
|
204
|
+
All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }`.
|
|
204
205
|
|
|
205
|
-
|
|
206
|
+
### 6.3 `messages.jsonl` — the human-reply ledger *(new in v2)*
|
|
206
207
|
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
One JSON object per line. Each is a **human's reply** to an ask. Messages are **events, not records**:
|
|
209
|
+
immutable, ids minted once, they **accumulate** (no fold) — an ask can carry answer → changes-requested
|
|
210
|
+
→ approved, and every one survives. A correction is a *new* message.
|
|
209
211
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
| Field | Type | Req | Meaning |
|
|
213
|
+
|---|---|:--:|---|
|
|
214
|
+
| `id` | string | ✓ | Event id, minted once by whoever creates the message (the reader, or the CLI for a transcription). Never hand-authored, never re-minted. |
|
|
215
|
+
| `kind` | `"answer"` \| `"verdict"` | ✓ | An answer (to a question) or a verdict (on a sign-off). |
|
|
216
|
+
| `ask` | string | ✓* | The ask id this replies to. *Optional only for reserved human-initiated kinds (`note`/`directive`), which may anchor to a run or stand alone. |
|
|
217
|
+
| `by` | string | ✓ | Who said it (the human). |
|
|
218
|
+
| `ts` | string (ISO-8601 w/ offset) | ✓ | Machine-stamped (server clock for reader-minted, CLI clock for transcribed). |
|
|
219
|
+
| `source` | `"app"` \| `"chat"` \| … | | **Where the reply arrived** (display metadata, *not* trust) — `app` = in the reader, `chat` = transcribed by the agent. Extensible (`slack`, `email`, …). |
|
|
220
|
+
| `chosen` | string | | The option cited, **verbatim** from the ask's `options[]`. |
|
|
221
|
+
| `text` | string | | Free-text reply. |
|
|
222
|
+
| `verdict` | `"approved"` \| `"changes-requested"` \| `"rejected"` | | On a `verdict` message. |
|
|
223
|
+
|
|
224
|
+
**The trust rule (normative):** a reader derives the *verified* grade from **mint origin** — a message
|
|
225
|
+
the reader minted itself is attested; a message that arrived via push (transcribed by an agent) is
|
|
226
|
+
self-reported, **whatever its `source` says.** `source` is display metadata; never trust input.
|
|
227
|
+
|
|
228
|
+
`messages.jsonl` is part of the wire (pushed up, §8) and the one file that syncs **down**. It is not
|
|
229
|
+
folded; readers and the CLI dedupe by event `id`.
|
|
230
|
+
|
|
231
|
+
## 7. `artifacts/` — attachments
|
|
232
|
+
|
|
233
|
+
Files attached to a moment via `attachments[]` on a run, ask, or close line. An attachment belongs to
|
|
234
|
+
the line that produced it (a checkpoint draft on its checkpoint, the deliverable on its report, proof
|
|
235
|
+
on its close) — **not** to the folded record, so an intermediate is never lost. Each file is
|
|
236
|
+
content-addressed (an unchanged file is skipped on publish; a re-attach of the same name with different
|
|
237
|
+
bytes is rejected — use a new name).
|
|
238
|
+
|
|
239
|
+
- **Renderable** (shown inline in a sandboxed viewer): `html`, `md`, `txt`, `json`, images
|
|
240
|
+
(`.png .jpg .jpeg .gif .webp .svg`).
|
|
241
|
+
- **Download-only** (offered as a download, never rendered — lower risk, nothing executes):
|
|
242
|
+
`.csv .pdf .xlsx .xls .docx`. Extensible.
|
|
243
|
+
- **Size:** keep each file **≤ ~10 MB**.
|
|
244
|
+
- Attachments are produced locally and **do not sync down** (a reference missing on a fresh clone is
|
|
245
|
+
shown as "view it in the app", not downloaded).
|
|
214
246
|
|
|
215
247
|
## 8. Publishing (the wire contract)
|
|
216
248
|
|
|
217
|
-
|
|
249
|
+
Authenticated by a per-user token in the **`Authorization: Bearer <token>`** header. (The reference CLI
|
|
250
|
+
also sends the legacy `x-figs-token` through the v1→v2 transition; readers may accept both until the
|
|
251
|
+
minimum CLI version requires Bearer.)
|
|
252
|
+
|
|
253
|
+
**Up — `push`** sends:
|
|
218
254
|
|
|
219
255
|
1. **The spine** → `POST {endpoint}/api/ingest`, body:
|
|
220
256
|
```jsonc
|
|
221
257
|
{
|
|
222
|
-
"workspaceId": "<uuid>",
|
|
223
|
-
"agent":
|
|
224
|
-
"runs":
|
|
225
|
-
"asks":
|
|
258
|
+
"workspaceId": "<uuid>", // from config.json
|
|
259
|
+
"agent": { /* agent.json */ },
|
|
260
|
+
"runs": [ /* runs.jsonl */ ], // optional
|
|
261
|
+
"asks": [ /* asks.jsonl */ ], // optional
|
|
262
|
+
"messages": [ /* messages.jsonl */ ], // optional — transcribed replies the reader lacks
|
|
263
|
+
"confirmRename": true // optional — `figs push --rename`: confirm a real name change
|
|
226
264
|
}
|
|
227
265
|
```
|
|
228
|
-
2. **Each
|
|
229
|
-
|
|
266
|
+
2. **Each attached file** → `POST {endpoint}/api/artifacts/upload`, base64-encoded, hash-verified.
|
|
267
|
+
|
|
268
|
+
The server upserts the agent by `id` and runs/asks by `id`; it dedupes messages by event `id`; it never
|
|
269
|
+
deletes. An agent **self-registers** on first push. **A push never walks a record backwards:** the
|
|
270
|
+
server refuses a fold older than the record's stored close/settle (a stale machine pushing old state)
|
|
271
|
+
and accepts a newer one (a legitimate reopen — the `warn` → `ok` evolution). **A push never re-homes an
|
|
272
|
+
agent:** a `workspaceId` differing from the agent's registered home is rejected `409`
|
|
273
|
+
`{ "error", "code": "agent_moved", "workspaceId"? }`; the agent recovers by setting
|
|
274
|
+
`config.json#workspaceId` to the named workspace and pushing again. **A push never silently
|
|
275
|
+
re-identifies an agent:** a `name` differing from the one registered for that `agentId` is the
|
|
276
|
+
fingerprint of a copied folder and is rejected `409` `{ "error", "code": "agent_renamed" }` — the
|
|
277
|
+
agent rotates identity (`rm -rf .figs && figs init`) for a genuinely new agent, or sets
|
|
278
|
+
`confirmRename` (`figs push --rename`) once to confirm a real rename. `name` is the signal because a
|
|
279
|
+
copy-to-make-another always renames while role/mandate evolve legitimately; the check is `name` only.
|
|
280
|
+
|
|
281
|
+
**Down — the reply sync.** Delivery is **agent-pulled**, never pushed into the repo: a reader exposes a
|
|
282
|
+
read returning **this agent's human messages** (answers/verdicts), which the CLI merges into
|
|
283
|
+
`messages.jsonl` (append-if-id-absent). It must return the agent's complete open surface or flag
|
|
284
|
+
truncation — a silently partial sync is forbidden. *(Only this — the human reply ledger — syncs down;
|
|
285
|
+
agent ledgers and attachments never do. The exact endpoint is finalized alongside the reader.)*
|
|
286
|
+
|
|
287
|
+
Because every push is authenticated, the receiver may stamp each newly created row with the pushing
|
|
288
|
+
identity ("pushed by") — it attributes the *credential*, not necessarily the human at the keyboard.
|
|
289
|
+
Agents never author this field.
|
|
230
290
|
|
|
231
|
-
|
|
232
|
-
on first push — there is no "create agent" step.
|
|
291
|
+
## 9. Validation & versioning
|
|
233
292
|
|
|
234
|
-
**
|
|
235
|
-
|
|
236
|
-
`
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
293
|
+
- **Local validation is normative for conformance** and runs account-free (`figs doctor`). A reader's
|
|
294
|
+
`POST {endpoint}/api/validate` is an additive second opinion, not the gate.
|
|
295
|
+
- **`figs-spec` is integer-versioned.** v2 is current. **Additive/optional** fields keep the version
|
|
296
|
+
number; the number bumps only on a **breaking** change. (Implementations report support via
|
|
297
|
+
`GET {endpoint}/api/version`.) v1 → v2 bumped because two-way reply flow and `messages.jsonl` are not
|
|
298
|
+
additive to v1's one-way promise.
|
|
299
|
+
- The spec stays intentionally minimal — extensions arrive as additive optional fields until a breaking
|
|
300
|
+
change is unavoidable.
|
|
241
301
|
|
|
242
|
-
|
|
243
|
-
newly created run/ask with that identity** ("pushed by"). This is server-observed — it attributes the
|
|
244
|
-
*credential*, not necessarily the human at the keyboard (a shared runner box should use a dedicated
|
|
245
|
-
account named for what it is, e.g. "Runner — analytics box"). Agents never author this field.
|
|
302
|
+
## Reserved (not in v2)
|
|
246
303
|
|
|
247
|
-
|
|
304
|
+
Named here so implementers don't repurpose these concepts:
|
|
248
305
|
|
|
249
|
-
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
version number (an older `agent.json` still validates). The number is bumped only on a **breaking**
|
|
253
|
-
change. (Implementations report support via `GET {endpoint}/api/version`.)
|
|
254
|
-
- v1 is intentionally minimal — it defines the smallest useful surface so we don't freeze the wrong
|
|
255
|
-
abstractions early. Extensions arrive as additive optional fields until a breaking change is unavoidable.
|
|
256
|
-
|
|
257
|
-
## Reserved (not in v1)
|
|
258
|
-
|
|
259
|
-
Deliberately out of scope for v1, named here so implementers don't repurpose these concepts:
|
|
260
|
-
|
|
261
|
-
- **Two-way / answer-down — thread events.** A human answer or sign-off flowing *back* to the agent
|
|
262
|
-
through Figs (vs. the agent resolving in its own workflow). v1 is report-only. The shapes are locked
|
|
263
|
-
so `options[]`/`resolution` are designed for them: server-side events keyed to the ask id —
|
|
264
|
-
`answer { by, ts, chosen?, text? }` where
|
|
265
|
-
`chosen` verbatim-matches an `options[]` entry · `verdict { by, ts, verdict: "approved" | "changes-requested" | "rejected", text? }`
|
|
266
|
-
for sign-offs. Answers/verdicts are permission-gated to the agent's manager/builder (the injection
|
|
267
|
-
gate); delivery is **agent-pulled** (an inbox read), never pushed into the repo. Item kinds `note`
|
|
268
|
-
and `directive` (human-initiated) are named-reserved.
|
|
306
|
+
- **Human-initiated messages — `note` / `directive`.** A human starting a thread ("also send the email
|
|
307
|
+
to X") rather than replying to an ask. The channel exists (`messages.jsonl` + the down-sync); these
|
|
308
|
+
kinds and their anchoring (to a run, or standalone) are named-reserved.
|
|
269
309
|
- **Provenance / signing.** Cryptographic attestation that a report is complete, fresh, and untampered.
|
|
270
|
-
|
|
271
|
-
- **Per-record visibility / scoping.**
|
|
310
|
+
v2 state is *self-reported*; treat it as visibility, not a tamper-evident audit trail.
|
|
311
|
+
- **Per-record visibility / scoping.** v2 publishes to a workspace where all members read everything.
|
|
312
|
+
- **Sync cursors / pagination.** The down-sync returns the agent's open surface whole (or flags
|
|
313
|
+
truncation); cursors arrive when scale demands.
|
|
272
314
|
|
|
273
315
|
## 10. A complete example
|
|
274
316
|
|
|
275
317
|
```jsonc
|
|
276
|
-
// .figs/config.json
|
|
277
|
-
{ "endpoint": "https://app.figs.so", "workspaceId": "…uuid…"
|
|
318
|
+
// .figs/config.json (linked; local mode would be just { "agentId": "…" })
|
|
319
|
+
{ "agentId": "…uuid…", "endpoint": "https://app.figs.so", "workspaceId": "…uuid…" }
|
|
278
320
|
```
|
|
279
321
|
|
|
280
322
|
```jsonc
|
|
@@ -283,7 +325,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
|
|
|
283
325
|
"name": "Reconciliation",
|
|
284
326
|
"role": "Reconciliation Officer",
|
|
285
327
|
"status": "in_dev",
|
|
286
|
-
"avatar": { "seed": "Reconciliation" },
|
|
287
328
|
"org": { "department": "Finance Ops" },
|
|
288
329
|
"runtime": "Claude Code",
|
|
289
330
|
"cadence": "Monthly",
|
|
@@ -294,10 +335,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
|
|
|
294
335
|
"Classify every key — matched / needs-review / our-side-only / customer-only — with a 'why'.",
|
|
295
336
|
"Surface discrepancies. Never write back to the source."
|
|
296
337
|
],
|
|
297
|
-
"properties": [
|
|
298
|
-
{ "k": "Data sources", "v": "Stripe · NetSuite" },
|
|
299
|
-
{ "k": "Escalation", "v": "#finance-ops" }
|
|
300
|
-
],
|
|
301
338
|
"units": [
|
|
302
339
|
{ "id": "acme", "name": "Acme Corp", "status": "88% matched · 31 keys flagged", "period": "2025-11",
|
|
303
340
|
"stats": [ { "l": "Matched", "v": "2,161 keys" }, { "l": "Needs review", "v": "31 keys" } ] }
|
|
@@ -306,25 +343,29 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
|
|
|
306
343
|
```
|
|
307
344
|
|
|
308
345
|
```jsonc
|
|
309
|
-
// .figs/runs.jsonl (
|
|
346
|
+
// .figs/runs.jsonl (records fold by id — the checkpoint opened the job, the report settled it)
|
|
310
347
|
{ "id": "acme-2025-11", "ts": "2026-05-28T23:05:40Z", "unit": "acme", "period": "2025-11", "state": "in-flight", "result": "Statements pulled — matching now",
|
|
311
|
-
"session": { "runtime": "claude-code", "trigger": "monthly close cron" } }
|
|
312
|
-
{ "id": "acme-2025-11", "ts": "2026-05-28T23:41:26Z", "unit": "acme", "period": "2025-11", "result": "88% matched · 31 keys flagged", "status": "ok", "state": "settled",
|
|
313
|
-
"session": { "runtime": "claude-code", "model": "claude-fable-5"
|
|
314
|
-
"startedAt": "2026-05-28T23:02:00Z", "commit": "1b68668",
|
|
315
|
-
"tokens": { "input": 26608, "output": 135532, "cacheRead": 8677869, "cacheWrite": 543145 } } }
|
|
348
|
+
"attachments": ["acme-wip.csv"], "session": { "runtime": "claude-code", "trigger": "monthly close cron" } }
|
|
349
|
+
{ "id": "acme-2025-11", "ts": "2026-05-28T23:41:26Z", "unit": "acme", "period": "2025-11", "result": "88% matched · 31 keys flagged", "status": "ok", "state": "settled",
|
|
350
|
+
"attachments": ["acme-2025-11.html"], "session": { "runtime": "claude-code", "model": "claude-fable-5" } }
|
|
316
351
|
```
|
|
317
352
|
|
|
318
353
|
```jsonc
|
|
319
|
-
// .figs/asks.jsonl (
|
|
320
|
-
{ "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "
|
|
354
|
+
// .figs/asks.jsonl (records fold by id — the close is an append, derived from the reply and citing it)
|
|
355
|
+
{ "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "question", "status": "open", "to": "manager", "unit": "acme", "run": "acme-2025-11",
|
|
321
356
|
"title": "No bridge rule for prefixed invoice numbers",
|
|
322
357
|
"found": "~180 rows can't be matched safely; guessing risks false matches.",
|
|
323
358
|
"need": "Confirm the bridge rule for prefixed invoice numbers.",
|
|
324
359
|
"options": [ "Strip the alpha prefix", "Use a mapping you provide", "Treat as out-of-scope" ],
|
|
325
360
|
"details": [ { "l": "Amount at risk", "v": "$50.0M" } ],
|
|
326
|
-
"
|
|
361
|
+
"attachments": [ "acme-2025-11.html" ] }
|
|
327
362
|
{ "id": "acme-bridge", "status": "resolved",
|
|
328
|
-
"resolution": { "chosen": "Strip the alpha prefix", "via": "
|
|
329
|
-
"
|
|
363
|
+
"resolution": { "chosen": "Strip the alpha prefix", "via": "figs", "answer": "msg-7f3a", "by": "Sarah (accounting)",
|
|
364
|
+
"run": "acme-bridge-fix-2025-11", "ts": "2026-06-01T09:12:00Z" } }
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```jsonc
|
|
368
|
+
// .figs/messages.jsonl (the human's replies — events, not folded; deduped by id)
|
|
369
|
+
{ "id": "msg-7f3a", "kind": "answer", "ask": "acme-bridge", "by": "Sarah (accounting)", "ts": "2026-06-01T09:10:00Z",
|
|
370
|
+
"source": "app", "chosen": "Strip the alpha prefix" }
|
|
330
371
|
```
|