@figs-so/cli 0.7.0 → 1.0.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 -37
- package/SPEC.md +214 -153
- package/figs.mjs +1166 -560
- package/package.json +1 -1
package/SPEC.md
CHANGED
|
@@ -1,53 +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
|
-
├──
|
|
29
|
-
├──
|
|
30
|
-
|
|
39
|
+
├── CONTRACT.md # agent-authored: what this agent surfaces / holds back (committed)
|
|
40
|
+
├── GUIDE.md # orientation breadcrumb, written by the CLI (committed)
|
|
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)
|
|
31
45
|
```
|
|
32
46
|
|
|
33
|
-
**Commit** `config.json` + `agent.json`
|
|
34
|
-
`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.
|
|
35
50
|
|
|
36
|
-
|
|
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.**
|
|
37
55
|
|
|
38
|
-
|
|
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.
|
|
39
59
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
## 3. `config.json` — identity (+ destination)
|
|
61
|
+
|
|
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.
|
|
64
|
+
|
|
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. |
|
|
45
70
|
|
|
46
71
|
## 4. `agent.json` — the charter
|
|
47
72
|
|
|
48
73
|
The agent's self-description. Authoring this and publishing makes the agent *appear*. The only field you
|
|
49
|
-
author that's required is `name` — **do not hand-author `id`**: `figs init` mints it into `config.json`
|
|
50
|
-
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.
|
|
51
76
|
|
|
52
77
|
| Field | Type | Req | Meaning |
|
|
53
78
|
|---|---|:--:|---|
|
|
@@ -63,7 +88,7 @@ the CLI attaches it on push. Everything else is optional and rendered when prese
|
|
|
63
88
|
| `mandate` | string | | One-paragraph statement of what it's responsible for. |
|
|
64
89
|
| `steps` | string[] | | **Ordered** procedure (numbered render). For pipeline-shaped agents. |
|
|
65
90
|
| `responsibilities` | string[] | | **Unordered** areas of work (bulleted render). For broad/mission agents. |
|
|
66
|
-
| `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. |
|
|
67
92
|
| `units` | `Unit[]` | | The instances/things the agent operates on (see below). |
|
|
68
93
|
|
|
69
94
|
Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipeline vs. a set of work areas.
|
|
@@ -82,175 +107,210 @@ Use **`steps`** *or* **`responsibilities`** depending on shape — a fixed pipel
|
|
|
82
107
|
|
|
83
108
|
## 5. `runs.jsonl` — activity
|
|
84
109
|
|
|
85
|
-
One JSON object per line (JSON Lines). **One record = one job** — a unit of work the agent's
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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*
|
|
120
|
+
ever closes a run — only the agent's own report settles its job.
|
|
91
121
|
|
|
92
122
|
| Field | Type | Req | Meaning |
|
|
93
123
|
|---|---|:--:|---|
|
|
94
124
|
| `id` | string | ✓ | Stable id (upsert key). |
|
|
95
|
-
| `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. |
|
|
96
126
|
| `unit` | string | | The `Unit.id` this run is about. |
|
|
97
127
|
| `period` | string | | |
|
|
98
|
-
| `result` | string | |
|
|
99
|
-
| `status` | `"ok"` \| `"warn"` \| `"fail"` | | Default `"ok"`. **Outcome, never lifecycle** —
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
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. |
|
|
102
133
|
|
|
103
134
|
### 5.1 `Session` — runtime metadata (optional)
|
|
104
135
|
|
|
105
136
|
An optional, **self-reported** block describing the runtime session that produced a run (or raised an
|
|
106
|
-
ask
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
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).
|
|
110
140
|
|
|
111
141
|
| Field | Type | Meaning |
|
|
112
142
|
|---|---|---|
|
|
113
|
-
| `runtime` | string | What ran it, e.g. `claude-code`, `codex
|
|
143
|
+
| `runtime` | string | What ran it, e.g. `claude-code`, `codex`. |
|
|
114
144
|
| `model` | string | Model id, e.g. `claude-fable-5`. |
|
|
115
145
|
| `sessionId` | string | The runtime's own session identifier. |
|
|
116
146
|
| `startedAt` | string (ISO-8601 w/ offset) | When this job began (the record's `ts` is when it was reported). |
|
|
117
|
-
| `commit` | string | The
|
|
118
|
-
| `
|
|
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. |
|
|
119
150
|
|
|
120
151
|
## 6. `asks.jsonl` — handoffs to a human
|
|
121
152
|
|
|
122
|
-
One JSON object per line. Each is something the agent needs a person to resolve
|
|
123
|
-
|
|
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.
|
|
124
155
|
|
|
125
156
|
| Field | Type | Req | Meaning |
|
|
126
157
|
|---|---|:--:|---|
|
|
127
158
|
| `id` | string | ✓ | Stable id (upsert key). |
|
|
128
|
-
| `type` | enum | ✓ | `
|
|
129
|
-
| `status` | enum | | `"open"` (default) \| `"resolved"` (the need was met) \| `"withdrawn"` (the **
|
|
130
|
-
| `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. |
|
|
131
162
|
| `title` | string | ✓ | The ask, in one line. |
|
|
132
163
|
| `unit` | string | | The `Unit.id` this concerns. |
|
|
133
|
-
| `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. |
|
|
134
165
|
| `found` | string | | What the agent found / why it's stuck. |
|
|
135
166
|
| `need` | string | | What it needs from the human. |
|
|
136
|
-
| `options` | string[] | | Candidate
|
|
137
|
-
| `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`. |
|
|
138
169
|
| `details` | `{ l, v }[]` | | Labelled facts (e.g. amount at risk). |
|
|
139
|
-
| `
|
|
170
|
+
| `attachments` | string[] | | File names under `artifacts/` attached to this ask (the exact content to review — §7). |
|
|
140
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": … }`. |
|
|
141
|
-
| `ts` | string (ISO-8601 w/ offset) | | |
|
|
142
|
-
| `session` | `Session` | | The session that raised this ask
|
|
172
|
+
| `ts` | string (ISO-8601 w/ offset) | | Machine-stamped. |
|
|
173
|
+
| `session` | `Session` | | The session that raised this ask. |
|
|
143
174
|
|
|
144
|
-
### 6.1 Lifecycle — two ledgers,
|
|
175
|
+
### 6.1 Lifecycle — two ledgers, two directions
|
|
145
176
|
|
|
146
|
-
An ask
|
|
177
|
+
An ask anchors a thread whose two halves are owned by different parties:
|
|
147
178
|
|
|
148
|
-
- **The agent's ledger** is `asks.jsonl` — only the agent writes here
|
|
149
|
-
(field-level merge
|
|
150
|
-
|
|
151
|
-
```jsonc
|
|
152
|
-
{ "id": "acme-bridge", "status": "resolved",
|
|
153
|
-
"resolution": { "chosen": "Strip the alpha prefix", "via": "human", "by": "Sarah (accounting)" } }
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
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
|
|
157
182
|
self-audit trail; the folded record the reader stores is one complete ask.
|
|
158
|
-
- **The human's ledger** is
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
161
187
|
|
|
162
|
-
|
|
163
|
-
`resolved` | `withdrawn`
|
|
164
|
-
**`rejected`** (a reject verdict in the reader's UI closes the ask immediately; the agent's
|
|
165
|
-
later resolution append folds onto it without reopening). Today resolution otherwise happens
|
|
166
|
-
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.
|
|
167
190
|
|
|
168
191
|
### 6.2 `Resolution` — how an ask closed
|
|
169
192
|
|
|
193
|
+
The close is derived from the newest reply on the ask and cites it.
|
|
194
|
+
|
|
170
195
|
| Field | Type | Meaning |
|
|
171
196
|
|---|---|---|
|
|
172
197
|
| `note` | string | The agent's one-line account of the close. |
|
|
173
|
-
| `chosen` | string | The decision taken — **verbatim** one of the ask's `options[]
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `answer` | string | The
|
|
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`. |
|
|
177
203
|
|
|
178
|
-
All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }
|
|
179
|
-
normalize it to the object form.
|
|
204
|
+
All fields optional; a bare-string `resolution` is shorthand for `{ "note": … }`.
|
|
180
205
|
|
|
181
|
-
|
|
206
|
+
### 6.3 `messages.jsonl` — the human-reply ledger *(new in v2)*
|
|
182
207
|
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
185
211
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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).
|
|
190
246
|
|
|
191
247
|
## 8. Publishing (the wire contract)
|
|
192
248
|
|
|
193
|
-
|
|
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:
|
|
194
254
|
|
|
195
255
|
1. **The spine** → `POST {endpoint}/api/ingest`, body:
|
|
196
256
|
```jsonc
|
|
197
257
|
{
|
|
198
|
-
"workspaceId": "<uuid>",
|
|
199
|
-
"agent":
|
|
200
|
-
"runs":
|
|
201
|
-
"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
|
|
202
263
|
}
|
|
203
264
|
```
|
|
204
|
-
2. **Each
|
|
205
|
-
|
|
265
|
+
2. **Each attached file** → `POST {endpoint}/api/artifacts/upload`, base64-encoded, hash-verified.
|
|
266
|
+
|
|
267
|
+
The server upserts the agent by `id` and runs/asks by `id`; it dedupes messages by event `id`; it never
|
|
268
|
+
deletes. An agent **self-registers** on first push. **A push never walks a record backwards:** the
|
|
269
|
+
server refuses a fold older than the record's stored close/settle (a stale machine pushing old state)
|
|
270
|
+
and accepts a newer one (a legitimate reopen — the `warn` → `ok` evolution). **A push never re-homes an
|
|
271
|
+
agent:** a `workspaceId` differing from the agent's registered home is rejected `409`
|
|
272
|
+
`{ "error", "code": "agent_moved", "workspaceId"? }`; the agent recovers by setting
|
|
273
|
+
`config.json#workspaceId` to the named workspace and pushing again.
|
|
274
|
+
|
|
275
|
+
**Down — the reply sync.** Delivery is **agent-pulled**, never pushed into the repo: a reader exposes a
|
|
276
|
+
read returning **this agent's human messages** (answers/verdicts), which the CLI merges into
|
|
277
|
+
`messages.jsonl` (append-if-id-absent). It must return the agent's complete open surface or flag
|
|
278
|
+
truncation — a silently partial sync is forbidden. *(Only this — the human reply ledger — syncs down;
|
|
279
|
+
agent ledgers and attachments never do. The exact endpoint is finalized alongside the reader.)*
|
|
280
|
+
|
|
281
|
+
Because every push is authenticated, the receiver may stamp each newly created row with the pushing
|
|
282
|
+
identity ("pushed by") — it attributes the *credential*, not necessarily the human at the keyboard.
|
|
283
|
+
Agents never author this field.
|
|
206
284
|
|
|
207
|
-
|
|
208
|
-
on first push — there is no "create agent" step.
|
|
285
|
+
## 9. Validation & versioning
|
|
209
286
|
|
|
210
|
-
**
|
|
211
|
-
|
|
212
|
-
`
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
287
|
+
- **Local validation is normative for conformance** and runs account-free (`figs doctor`). A reader's
|
|
288
|
+
`POST {endpoint}/api/validate` is an additive second opinion, not the gate.
|
|
289
|
+
- **`figs-spec` is integer-versioned.** v2 is current. **Additive/optional** fields keep the version
|
|
290
|
+
number; the number bumps only on a **breaking** change. (Implementations report support via
|
|
291
|
+
`GET {endpoint}/api/version`.) v1 → v2 bumped because two-way reply flow and `messages.jsonl` are not
|
|
292
|
+
additive to v1's one-way promise.
|
|
293
|
+
- The spec stays intentionally minimal — extensions arrive as additive optional fields until a breaking
|
|
294
|
+
change is unavoidable.
|
|
217
295
|
|
|
218
|
-
|
|
219
|
-
newly created run/ask with that identity** ("pushed by"). This is server-observed — it attributes the
|
|
220
|
-
*credential*, not necessarily the human at the keyboard (a shared runner box should use a dedicated
|
|
221
|
-
account named for what it is, e.g. "Runner — analytics box"). Agents never author this field.
|
|
296
|
+
## Reserved (not in v2)
|
|
222
297
|
|
|
223
|
-
|
|
298
|
+
Named here so implementers don't repurpose these concepts:
|
|
224
299
|
|
|
225
|
-
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
version number (an older `agent.json` still validates). The number is bumped only on a **breaking**
|
|
229
|
-
change. (Implementations report support via `GET {endpoint}/api/version`.)
|
|
230
|
-
- v1 is intentionally minimal — it defines the smallest useful surface so we don't freeze the wrong
|
|
231
|
-
abstractions early. Extensions arrive as additive optional fields until a breaking change is unavoidable.
|
|
232
|
-
|
|
233
|
-
## Reserved (not in v1)
|
|
234
|
-
|
|
235
|
-
Deliberately out of scope for v1, named here so implementers don't repurpose these concepts:
|
|
236
|
-
|
|
237
|
-
- **Two-way / answer-down — thread events.** A human answer or sign-off flowing *back* to the agent
|
|
238
|
-
through Figs (vs. the agent resolving in its own workflow). v1 is report-only. The shapes are locked
|
|
239
|
-
so `options[]`/`resolution` are designed for them: server-side events keyed to the ask id —
|
|
240
|
-
`answer { by, ts, chosen?, text? }` where
|
|
241
|
-
`chosen` verbatim-matches an `options[]` entry · `verdict { by, ts, verdict: "approved" | "changes-requested" | "rejected", text? }`
|
|
242
|
-
for sign-offs. Answers/verdicts are permission-gated to the agent's manager/builder (the injection
|
|
243
|
-
gate); delivery is **agent-pulled** (an inbox read), never pushed into the repo. Item kinds `note`
|
|
244
|
-
and `directive` (human-initiated) are named-reserved.
|
|
300
|
+
- **Human-initiated messages — `note` / `directive`.** A human starting a thread ("also send the email
|
|
301
|
+
to X") rather than replying to an ask. The channel exists (`messages.jsonl` + the down-sync); these
|
|
302
|
+
kinds and their anchoring (to a run, or standalone) are named-reserved.
|
|
245
303
|
- **Provenance / signing.** Cryptographic attestation that a report is complete, fresh, and untampered.
|
|
246
|
-
|
|
247
|
-
- **Per-record visibility / scoping.**
|
|
304
|
+
v2 state is *self-reported*; treat it as visibility, not a tamper-evident audit trail.
|
|
305
|
+
- **Per-record visibility / scoping.** v2 publishes to a workspace where all members read everything.
|
|
306
|
+
- **Sync cursors / pagination.** The down-sync returns the agent's open surface whole (or flags
|
|
307
|
+
truncation); cursors arrive when scale demands.
|
|
248
308
|
|
|
249
309
|
## 10. A complete example
|
|
250
310
|
|
|
251
311
|
```jsonc
|
|
252
|
-
// .figs/config.json
|
|
253
|
-
{ "endpoint": "https://app.figs.so", "workspaceId": "…uuid…"
|
|
312
|
+
// .figs/config.json (linked; local mode would be just { "agentId": "…" })
|
|
313
|
+
{ "agentId": "…uuid…", "endpoint": "https://app.figs.so", "workspaceId": "…uuid…" }
|
|
254
314
|
```
|
|
255
315
|
|
|
256
316
|
```jsonc
|
|
@@ -259,7 +319,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
|
|
|
259
319
|
"name": "Reconciliation",
|
|
260
320
|
"role": "Reconciliation Officer",
|
|
261
321
|
"status": "in_dev",
|
|
262
|
-
"avatar": { "seed": "Reconciliation" },
|
|
263
322
|
"org": { "department": "Finance Ops" },
|
|
264
323
|
"runtime": "Claude Code",
|
|
265
324
|
"cadence": "Monthly",
|
|
@@ -270,10 +329,6 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
|
|
|
270
329
|
"Classify every key — matched / needs-review / our-side-only / customer-only — with a 'why'.",
|
|
271
330
|
"Surface discrepancies. Never write back to the source."
|
|
272
331
|
],
|
|
273
|
-
"properties": [
|
|
274
|
-
{ "k": "Data sources", "v": "Stripe · NetSuite" },
|
|
275
|
-
{ "k": "Escalation", "v": "#finance-ops" }
|
|
276
|
-
],
|
|
277
332
|
"units": [
|
|
278
333
|
{ "id": "acme", "name": "Acme Corp", "status": "88% matched · 31 keys flagged", "period": "2025-11",
|
|
279
334
|
"stats": [ { "l": "Matched", "v": "2,161 keys" }, { "l": "Needs review", "v": "31 keys" } ] }
|
|
@@ -282,23 +337,29 @@ Deliberately out of scope for v1, named here so implementers don't repurpose the
|
|
|
282
337
|
```
|
|
283
338
|
|
|
284
339
|
```jsonc
|
|
285
|
-
// .figs/runs.jsonl (
|
|
286
|
-
{ "id": "acme-2025-11", "ts": "2026-05-28T23:
|
|
287
|
-
"session": { "runtime": "claude-code", "
|
|
288
|
-
|
|
289
|
-
|
|
340
|
+
// .figs/runs.jsonl (records fold by id — the checkpoint opened the job, the report settled it)
|
|
341
|
+
{ "id": "acme-2025-11", "ts": "2026-05-28T23:05:40Z", "unit": "acme", "period": "2025-11", "state": "in-flight", "result": "Statements pulled — matching now",
|
|
342
|
+
"attachments": ["acme-wip.csv"], "session": { "runtime": "claude-code", "trigger": "monthly close cron" } }
|
|
343
|
+
{ "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",
|
|
344
|
+
"attachments": ["acme-2025-11.html"], "session": { "runtime": "claude-code", "model": "claude-fable-5" } }
|
|
290
345
|
```
|
|
291
346
|
|
|
292
347
|
```jsonc
|
|
293
|
-
// .figs/asks.jsonl (
|
|
294
|
-
{ "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "
|
|
348
|
+
// .figs/asks.jsonl (records fold by id — the close is an append, derived from the reply and citing it)
|
|
349
|
+
{ "id": "acme-bridge", "ts": "2026-05-28T21:05:00Z", "type": "question", "status": "open", "to": "manager", "unit": "acme", "run": "acme-2025-11",
|
|
295
350
|
"title": "No bridge rule for prefixed invoice numbers",
|
|
296
351
|
"found": "~180 rows can't be matched safely; guessing risks false matches.",
|
|
297
352
|
"need": "Confirm the bridge rule for prefixed invoice numbers.",
|
|
298
353
|
"options": [ "Strip the alpha prefix", "Use a mapping you provide", "Treat as out-of-scope" ],
|
|
299
354
|
"details": [ { "l": "Amount at risk", "v": "$50.0M" } ],
|
|
300
|
-
"
|
|
355
|
+
"attachments": [ "acme-2025-11.html" ] }
|
|
301
356
|
{ "id": "acme-bridge", "status": "resolved",
|
|
302
|
-
"resolution": { "chosen": "Strip the alpha prefix", "via": "
|
|
303
|
-
"
|
|
357
|
+
"resolution": { "chosen": "Strip the alpha prefix", "via": "figs", "answer": "msg-7f3a", "by": "Sarah (accounting)",
|
|
358
|
+
"run": "acme-bridge-fix-2025-11", "ts": "2026-06-01T09:12:00Z" } }
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
```jsonc
|
|
362
|
+
// .figs/messages.jsonl (the human's replies — events, not folded; deduped by id)
|
|
363
|
+
{ "id": "msg-7f3a", "kind": "answer", "ask": "acme-bridge", "by": "Sarah (accounting)", "ts": "2026-06-01T09:10:00Z",
|
|
364
|
+
"source": "app", "chosen": "Strip the alpha prefix" }
|
|
304
365
|
```
|