On this page
Architecture
Data Vault.
How Anton asks for credentials, tests them, and saves them — without ever letting the values round-trip through chat history. The flow stitches together a chat tool, a side-panel form, a server-side cowork agent, and a fresh headless instance of Anton acting purely as a connection prober. This page documents how those pieces talk.
Overview
Anton runs locally. When the user asks to connect a service (Postgres, Gmail, PostHog,
Salesforce, anything), Anton calls a tool called request_credentials. The tool
emits a JSON form spec wrapped in a data-vault-form markdown block. The
renderer's markdown extension peels the block out, publishes the spec into a per-conversation
store, and the side panel mounts the form.
When the user submits, the panel posts to /v1/datavault/submissions — values
are staged in memory (never written to disk), and the response opens an SSE stream. The
cowork agent on the server is the boss of that stream: it spins up a separate, headless
Anton instance whose only job is to test the connection. The probe runs in the scratchpad,
reports back via four small tools, and the cowork agent translates those reports into:
- tiny
data-vault-form-patchblocks that update the panel live (a status bubble, per-field statuses, error/success state) - a brief verdict line in the chat bubble
- a vault save (only on success)
Why a vault?
Three constraints drive every decision in this document:
- Credentials never enter chat history. Form values stay on the user's machine, in
~/.anton/vault. Anton's chat history (used as LLM context) only ever sees field names and asubmission_id. - Anton's chat instance never sees the probe. The probe runs in a fresh
ChatSessionwith empty history and no persistence — its prompt, its scratchpad cells, and its verdict tools live and die entirely on the server. - Any engine can be probed. There's no per-engine snippet registry. Anton picks the right client library at probe time. PostHog, custom HTTP APIs, weird internal services — all the same path.
Actors
Six pieces collaborate. None of them know about each other directly — they're glued by the form spec, the patch dialect, and the SSE stream.
server/anton_api/cowork_tools.pyThe tool Anton calls in chat to start a connection. Takes a form spec (engine, fields, optional methods). Returns a data-vault-form block to embed verbatim in the assistant message.
src/renderer/cowork/components/datavault/Side-panel host. Subscribes to the per-conversation form store; renders the form, the live status toast, the per-field status spinners, the success and failure states. Submits via the streaming endpoint.
server/anton_api/datavault_submissions.pyShort-TTL submission store. Holds the staged values keyed by submission_id. 24h TTL, never written to disk. Anton's fetch_submission tool reads it.
server/anton_api/datavault_agent.pyThe boss. Runs as a single SSE stream — stages the values, runs the probe, translates probe events into form patches and chat deltas, persists the synthesized turn, and saves to vault on success.
server/anton_api/datavault_probe.pyA fresh ChatSession with empty history, no history_store, no session_id. Has the standard scratchpad tool plus six probe-specific tools. Runs once per submission; nothing it does survives the call.
~/.anton/vaultThe actual credential store. Per-engine, per-name files. Anton uses vault.inject_env(...) to expose values as DS_<ENGINE>_<NAME>__<FIELD> environment variables in the scratchpad.
Architecture diagram
The full lifecycle, from request_credentials to a saved vault entry. The
cowork agent is the only component aware of every other piece — by design.
turn]:::chat RC["request_credentials
(cowork tool)"]:::chat end subgraph Renderer[Renderer · React side panel] direction TB MD["MarkdownContent
peels data-vault-form fence"]:::ui Store["formStore
per-conversation pub/sub"]:::ui Panel["DataVaultFormPanel
+ DataVaultForm"]:::ui end subgraph Cowork[Cowork server] direction TB Endpoint["POST /v1/datavault/
submissions (SSE)"]:::agent Subs["datavault_submissions
(24h TTL, in-memory)"]:::store Agent["datavault_agent
process_submission_stream"]:::agent EnvFile[".env temp file
tempfile.mkstemp"]:::store end subgraph Probe[Probe · headless Anton] direction TB Sess["fresh ChatSession
no history · no persistence"]:::probe Pad["scratchpad cell
parses .env, runs test"]:::probe Tools["set_status · set_field_status
request_extra_field
switch_method · remove_field
report_success · report_failure"]:::probe end Vault["LocalDataVault
~/.anton/vault"]:::vault Chat["chat history
(values NEVER stored)"]:::store User -- "asks to connect" --> LLM LLM --> RC RC -- "data-vault-form block
(no values)" --> MD MD -- "setForm(spec)" --> Store Store -- "subscribe" --> Panel Panel -- "user fills, clicks submit" --> Endpoint Endpoint -- "stage values" --> Subs Endpoint --> Agent Agent -- "writes credentials" --> EnvFile Agent -- "spawn + drive" --> Sess Sess -- "scratchpad call" --> Pad Pad -- "reads env file
tries connection" --> Tools Tools -- "set_status / patches" --> Agent Agent -- "data-vault-form-patch
SSE deltas" --> Panel Agent -- "report_success" --> Vault Agent -- "verdict line" --> Chat Agent -- "delete temp file" --> EnvFile linkStyle default stroke:#6B6F73,stroke-width:1px;
End-to-end flow
Eight phases. Most are sub-second; the probe phase is the only one that takes user-visible time, capped at 90 seconds.
1. Anton calls the tool
In a normal chat turn, Anton sees the user wants to connect to a service and calls
request_credentials with a JSON spec. The tool returns an instruction to
emit the spec verbatim wrapped in a data-vault-form fence.
2. Markdown extension lifts the spec to the side panel
The renderer's markdown extension recognises data-vault-form as a custom
language. MarkdownCode.jsx parses the JSON tolerantly (smart quotes,
trailing commas, JS comments), publishes the spec into formStore keyed by
conversation id, and renders nothing inside the chat bubble — a one-time pointer card
links the user to the side panel.
3. The form panel renders
DataVaultFormPanel subscribes to formStore for the current
conversation. It mounts inside a RailCard (no chevron) with a × close
affordance. A subtle slide-up + fade animation runs on first appearance, never on
subsequent patches.
4. User submits
The panel POSTs to /v1/datavault/submissions with form_id, the values, the
list of skipped field names, and the form spec (spread with auth_method /
selected_method when the user chose a method). The response is SSE.
5. Cowork agent runs
datavault_agent.process_submission_stream opens a single SSE turn. Stages
the values in datavault_submissions (24h TTL, in-memory only). Validates
shape if the engine is registered (skipped for custom engines). Writes credentials to a
temp .env file at tempfile.mkstemp(prefix='anton-vault-', suffix='.env').
6. Probe spins up
datavault_probe.run_probe instantiates a fresh ChatSession
with the conversation's llm_client + workspace but no
history_store, no session_id, and an empty initial history.
Anton's standard scratchpad tool is auto-registered; six probe-specific tools are added
on top. The system prompt is a tight technical brief: where the .env is, what variable
names are inside, the field roster (with filled / empty / skipped state per field),
and instructions to call exactly one verdict tool before stopping.
7. Live updates flow back
Each scratchpad cell, each set_status / set_field_status
call, each piece of prose anton emits — the cowork agent translates them into the
client's existing SSE vocabulary (response.in_progress for scratchpad,
data-vault-form-patch blocks for status, plain text deltas for prose).
The form panel's status toast updates live; per-field spinners appear and disappear;
the chat bubble fills incrementally.
8. Verdict lands
Anton calls report_success, report_failure, or
request_extra_field. The cowork agent reacts:
- success → save to
LocalDataVault, emit a final form patch flipping the panel to its celebratory state with two actions: Close + View connectors →. - failure → leave the vault clean, patch the form with an error banner + the user-friendly diagnosis, restore the input grid for editing.
- needs_input → patch the form with new fields appended (scoped to the active method when multi-method), update the subtitle to the reason. The user re-submits with the merged credentials and the probe runs again.
In every branch, the temp .env file is unlinked in finally. The synthesized
turn (intro line + status patches + scratchpad events + verdict text) is persisted to the
conversation's _history.json + per-turn events sidecar so reload reproduces
exactly what the user saw.
Form spec
The shape Anton emits via request_credentials. Every field is optional except
engine and title — the absence of fields is
meaningful (it implies multi-method via the methods array).
{ "form_id": "fm_a3f9c2b41e", // stable id; reuse across patches "engine": "posthog", // any string — registry-known or custom "logo": "key", // icon name (NOT a URL) "logo_color": "var(--accent)", "title": "Connect to PostHog", "subtitle": "Read-only access via personal API key.", "form_warning": null, // optional amber banner "form_error": null, // optional red banner "status_text": null, // live status — set by patches "_is_probing": false, // flips while the probe runs "fields": [ { "name": "api_key", // env-var-like; becomes DS_API_KEY "label": "Personal API key", "type": "password", // text|password|url|select|textarea|boolean "required": true, "placeholder": "phx_…", "help": "From posthog.com/settings", "skipable": false // every field is skipable by default } ], "actions": [ // optional; default is Submit + Cancel { "id": "submit", "label": "Connect", "kind": "primary" } ] }
Field types
text— single-line text input.password— masked input, monospace.url— same as text but usestype="url"for browser hints.select— dropdown; provide anoptionsarray of{value, label}.textarea— multiline, monospace (good for service-account JSON).boolean— checkbox;checkbox_labeloverrides the rendered label.
Per-field state
Three slots can be patched independently — set on a retry to call out per-field issues:
error— red text under the input. Outcome (e.g. Token rejected).warning— amber text under the input.status— gray text + small spinner under the input. Activity (e.g. Validating…). Set transiently by the probe; cleared withnull.help— muted helper text. Hidden when error / warning / status are set.
Multi-method forms
When a service supports several auth options, the form spec uses methods[]
instead of (or alongside) fields[]. The user picks a method first; the form
switches to that method's field list. Submission carries an auth_method
tag so the probe knows which path to test.
{ "form_id": "fm_gmail_1", "engine": "gmail", "title": "Connect Gmail", "selected_method": "app_password", // optional pre-pick "methods": [ { "id": "app_password", "label": "App Password", "description": "Easiest if you use Gmail with 2FA. 16-char key.", "recommended": true, "help_url": "https://support.google.com/accounts/answer/185833", "fields": [ { "name": "email", "label": "Email", "type": "text", "required": true }, { "name": "app_password", "label": "App Password", "type": "password", "required": true } ] }, { "id": "service_account", "label": "Service Account", "description": "Workspace only. Domain-wide delegation.", "fields": [ { "name": "service_account_json", "type": "textarea", "label": "JSON key" } ] } ] }
UX
- No
selected_method→ form panel renders a vertical stack of method cards (label + description + Recommended pill + How-to link). User clicks a card to pick. - Selected → small breadcrumb above the fields:
METHOD: App Password (change). Click change to re-open the picker. Per-(form, method) input state means switching back keeps what was typed. - On submit, the form spec is spread with
auth_method: <id>+selected_method: <id>so the agent reads it from either key.
Patch dialect
Once a form is on screen, every subsequent update flows as a
data-vault-form-patch block. Patches are never rendered in
the chat bubble — the markdown extension routes them straight to the form store, and the
store merges them into the existing form by id.
Three nesting levels, three independent semantics for null:
{ "form_id": "fm_a3f9c2b41e", // 1) Top-level: overwrite. null clears the key. "subtitle": "Probe failed — fix the highlighted issue.", "status_text": null, // clears the toast "_is_success": true, // 2) Inside fields[]: name-keyed; object merges, null DELETES the field. "fields": { "api_key": { // merges into existing field "error": "Token rejected", "status": null // clears just the status property }, "region": null // removes the whole field }, // 3) Inside methods[]: id-keyed; object merges, null DELETES the method. "methods": { "app_password": { "fields": { // nested method.fields uses the same rules "app_password": { "error": "Wrong format — should start with phx_" } } } } }
value, which would put credentials in chat
history. Patches are append-only — values stay in the renderer's local state.
Headless probe
The probe is a second Anton, instantiated specifically to test one credential set.
It uses the conversation's LLM client + workspace, but starts with empty history and never
persists. Its ChatSession dies when the probe ends; the only side effect that
survives the call is the verdict.
Why a second Anton?
- No prompt pollution. The technical "you are a connection prober" instructions never end up in the user's conversation history.
- No tool conflicts. Conversation Anton has tools like
request_credentialsandpublish; the probe has onlyscratchpad+ the six probe tools. Smaller surface, fewer ways to drift. - Single-shot. The probe runs
turn_streamonce and exits. No retries, no continued multi-turn reasoning.
The .env handoff
The cowork agent writes the user's submitted credentials to a tempfile in standard .env
format:
DS_EMAIL="alice@example.com" DS_APP_PASSWORD="abcd efgh ijkl mnop"
The path is the only credential channel that reaches the probe. The probe parses the file
inside the scratchpad (typically with dotenv_values) and uses the values to call
the engine's client. The probe is explicitly told never to print credential values
— they would otherwise leak into the right-rail scratchpad output. The cowork agent
unlinks the tempfile in finally regardless of outcome.
The prompt
Built dynamically per probe. Includes the engine, the env path, the variable names, and a roster of the form's fields with filled / empty / skipped state. For multi-method forms it lists every method, marks the selected one, and indents each method's fields separately so anton can choose to add fields to the right method.
Probe tools
Six tools, plus the standard scratchpad tool that ChatSession auto-registers.
All accept a method_id parameter when relevant — multi-method forms scope
edits to a specific method's field list.
status=null clears. Used for granular feedback ("Validating…" under api_key).
selected_method. The reason renders as a status toast so the user sees why anton suggests the swap.
error is the user-friendly diagnosis; follow_up is a one-line hint about what to fix.
Verdicts
Every probe ends with exactly one verdict. If anton stops without firing one (rare; usually
a timeout), the runner forces a failure with "Probe ended without a verdict."
~/.anton/vault via LocalDataVault.
Form flips to its celebratory state with two actions: Close + View connectors →.
form_error banner + restored input grid.
User edits and resubmits.
failure. error is "Probe timed out after 90s."
follow_up nudges the user to retry or check service reachability.
Form panel UI
The right-rail panel uses a RailCard with noChevron — the only
dismissal control is the × at the top right. Below the card header, four UI primitives
compose every state the form can be in:
Header
Logo (icon name from the app palette — never a URL) + title + subtitle. Always rendered.
Status toast
Sits between the header and the form body. Shows the current status_text
with a small accent-colored spinner and a × to dismiss. Dismissal only suppresses the
exact text the user closed — a new status update auto-resurfaces the toast.
This keeps the form layout from jumping while the probe streams progress.
Method picker / breadcrumb
Multi-method only. Picker = vertical card stack; breadcrumb = single line above fields with a "change" link. Per-(form, method) local state means the user can flip between methods without losing typed values.
Fields + actions
Every field has a label, an input, and three optional message slots (error / warning / status). Per-field skip toggle (skip / unskip) on the right of every label. Actions row at the bottom — primary button is the only filled style. While probing, inputs are disabled (busy state) but still visible — no layout jumps.
Success state
Triggered by _is_success: true. Replaces fields + actions with a green check,
the title, the subtitle (anton's success summary), and two default actions:
- Close — secondary; just dismisses the panel.
- View connectors → — primary; routes to the Connect Apps and Data page where the user can rename, remove, or attach the new connection.
Animation
A subtle fade + slide-up + scale (320ms) on first appearance — re-fires only when a
new form_id arrives. Patches into the same form_id don't re-trigger it. The
spinner uses a 720ms linear rotation. Both keyframes inject into document.head
once at module load.
Endpoints
Submission stream
POST /v1/datavault/submissions Content-Type: application/json { "form_id": "fm_a3f9c2b41e", "conversation_id": "task-2025-Q3", "values": { "api_key": "phx_…" }, "skipped": [], "form_spec": { /* same shape Anton emitted, with auth_method spread in if multi-method */ } }
Returns a Server-Sent Events stream — same event vocabulary as /v1/responses
(so the renderer's existing SSE adapter consumes it natively). The stream emits the
cowork agent's text deltas, the probe's translated scratchpad events, and the
data-vault-form-patch blocks that drive the panel.
Submission peek (debug)
GET /v1/datavault/submissions/{submission_id}
Returns metadata + field names for a staged submission. Values are intentionally
redacted from this endpoint — only the agent-side fetch_submission tool
reads them, via a different code path that doesn't go through HTTP.
Datasource CRUD
Direct vault access (used by the Connect Apps and Data page, not the chat workflow):
GET /v1/datasources— list saved connections.POST /v1/datasources/validate— shape-check without saving.POST /v1/datasources— save (used by the legacy connect form, not the chat-driven path).DELETE /v1/datasources/{engine}/{name}— remove a saved connection.
SSE events
The submission stream uses the same five top-level events as /v1/responses:
thought_role: scratchpad.start/end/result) — feeds the right rail.data-vault-form-patch blocks.response.status reads success, retry, needs_input, or failed.Security model
Storage
- Saved credentials live at
~/.anton/vault/<engine>/<name>.json. Read/write only viaLocalDataVault. - Submission staging (
datavault_submissions) is in-memory with a 24h TTL — never written to disk. Cleared on server restart. - The temp .env file lives in
tempfile.mkstemp(prefix='anton-vault-', suffix='.env')for the duration of one probe (typically < 60s). Always unlinked infinally.
Chat history
- Form submissions never echo values back into chat. The continuation message that hits Anton's chat history references the
submission_id+ the names of submitted/skipped fields only. - The cowork agent's synthesized turn (intro line + verdict text) is persisted, but no patches inside it carry values — the patch markdown is rendered as nothing in chat.
- Anton's
fetch_submissiontool is the only read path. It runs server-side and returns values in the tool's response (which is part of LLM context for that one turn but isn't shown in the chat bubble).
Probe isolation
- The probe ChatSession has
history_store=Noneandsession_id=None— nothing it does survives the call. - Probe instructions explicitly forbid printing credential values. The scratchpad output is shown in the right rail, so leaks would be visible.
- The probe has no access to
LocalDataVault(passed asdata_vault=None). Vault writes happen in the cowork agent, afterreport_success.
Trust boundaries
- Loopback only. The cowork server binds to
127.0.0.1; CORS is locked to the renderer origin. No external network entry point. - Local credentials. Nothing is sent to Anthropic or any LLM provider unless anton's probe explicitly opts in (e.g. for an LLM-based connection like Anthropic's API as an "engine"). The probe prompt instructs anton not to.
File map
Where each piece of the puzzle lives. Read in this order to follow the flow.
request_credentials tool + fetch_submission + update_form. The chat-side surface; what the LLM sees.
POST /v1/datavault/submissions + GET /v1/datavault/submissions/{id}. Wraps process_submission_stream in a StreamingResponse.
stage_submission, get_submission, consume_submission, lazy TTL purge.
process_submission_stream orchestrates the probe and translates events into the SSE vocabulary the client speaks.
run_probe + tool definitions. Builds a fresh ChatSession, runs turn_stream, drives the probe-tools event loop, manages the temp .env file.
setForm, patchForm (with the three-level merge semantics), clearForm, subscribe.
data-vault-form + -patch blocks out of chat into the form store. Renders nothing for patches; one-time pointer card for full forms.