Purpose: define the single control boundary (ADR-0003). This is a contract sketch, not final IDL — it fixes shape, naming, error model, events, and versioning so CLI/MCP/UI can be designed against it now.
1. Transport — DECIDED (ADR-0006, supersedes ADR-0005)
- Canonical: localhost HTTP/1.1 + JSON, REST-resource-oriented, bound to
127.0.0.1, documented with OpenAPI 3.1 (contract/openapi.yaml) → codegen CLI/MCP/SDK + Postman. (3.1 is provisional — the codegen spike may down-shift to 3.0.3 for Rust tooling; CODEGEN_PIPELINE §4.) - Auth: bearer API token. Port + token published to a per-user discovery file
(
~/.vflin/local-api.json) — AdsPower-style; clients read it to connect. - Events/streaming: WebSocket at
/events(subscribe, bidi); SSE one-way fallback. - Optional OS-secure channel: the same handlers also served over UDS / Named Pipe for local-only clients (UI, CLI) that want no TCP surface. Handlers stay transport-agnostic.
- The cloud is reached only by the Core’s Sync Client, never by Local API clients.
- Full rationale + alternatives:
TRANSPORT_COMPARISON.md(internal:docs/20-control-plane/TRANSPORT_COMPARISON.md).
2. Conventions
- Namespaced methods:
domain.action(e.g.profile.start). - All methods take a single typed object; all return
{ ok, data?, error? }. - IDs are UUIDv4 strings. Timestamps are RFC3339 UTC.
- Every mutating call carries an idempotency key and a
mode(policy mode).
3. Error model
error: { code: string, message: string }
Stable string codes, not numbers. The complete list, with the HTTP status each maps to, is the table in §8 — it is generated from nothing and checked against the daemon by a test, so it is the one place to read and the one place to edit.
retryable and details were in this shape and are not in the wire envelope: retryable was removed
2026-08-16 because every arm produced it and no client read it, and details was never built. The four
codes this paragraph used to name — PROFILE_LOCKED_REMOTE, PROFILE_DIRTY, FINGERPRINT_INVALID,
VAULT_LOCKED — were written on 2026-06-17 in the Phase-0 architecture base, before a daemon existed,
and none of them was ever implemented. See §8 for what the daemon actually returns.
4. Method catalog (v0 sketch)
profile
profile.create({ name, fingerprintRef|fingerprint, proxyRef?, group? }) → { id }profile.list({ filter?, group? }) → { profiles[] }profile.get({ id }) → { profile, state }profile.start({ id, headless?, mode }) → { handle, cdpEndpoint }profile.stop({ id, sync? }) → { state }profile.materialize({ id }) → { path, state }profile.delete({ id, hard?, mode }) → { ok }(hard delete = danger mode)
profile.session (Feature c — Sessions module, ADR-0034)
Bind a saved service session so a profile opens already logged in. Secrets ride the vault by reference;
these methods never return one. Operator guide: SESSIONS_MODULE.md (internal: docs/20-control-plane/SESSIONS_MODULE.md).
session.recipe.list() → { recipes[] }(green — the supported-service catalog)profile.session.list({ id }) → { sessions[] }·profile.session.get({ id, service }) → { attached, binding? }profile.session.set({ id, service, mode, secrets, label? }) → { attached, binding }(danger)profile.session.clear({ id, service }) → { attached }(danger — also wipes the vaulted secrets)
fingerprint
fingerprint.generate({ constraints }) → { config }fingerprint.validate({ config }) → { valid, issues[] }fingerprint.catalog.list() → { presets[] }fingerprint.generateFromHost({ timezone, language, screen* }) → { config }— host-inversion (ADR-0031 B-local): device dims host-real, free dims from the request.NOT_FOUNDuntil the host is characterized.node.characterize() → { NodeCapability }— the B-local host capture through the ONE control boundary (invariant 1): launches the core on THIS device, caches the host fingerprint sogenerateFromHost/fromHostcan derive host-coherent identities (was the standalonecapture-profilebinary).
proxy
proxy.create / proxy.list / proxy.test({ id }) → { ok, ip, latencyMs }proxy.bind({ profileId, proxyId })
vault (all gated by mode + audited)
vault.get({ key, mode }) · vault.put({ key, value }) · vault.export({ scope, mode })
automation
automation.script.upsert / listautomation.run({ scriptId, target: {profileIds|group}, vars? }) → { runId }automation.status({ runId }) · automation.cancel({ runId })automation.group.upsert / schedule
sync
sync.push({ id }) · sync.pull({ id }) · sync.status({ id })sync.lock.acquire({ id }) · sync.lock.release({ id })
core
core.list() → { providers[], versions[] }core.select({ providerId, version }) · core.diagnose() → { report }
system
system.health() · system.policy.get/set({ mode }) · log.tail({ filter })
transfer (A222, ISSUE-651 — profiles from another program)
transfer.vision.read({ token }) → VisionCatalog— the person’s Vision read with their own token,GETonly (https://api.browser.vision/api/v1,X-Token); nothing in Vision changes, the token is never written.transfer.vision.start({ token, profiles[], local }) → TransferJob(danger, pollstransfer.job) — each profile made the wayprofile.createmakes one, on its exit (login and password into the vault), then Vision’s fingerprint over it field by field (K98), the claimed Chrome at this engine’s; tags (none —Vision), note, status, the folder’s colour and icon, cookies waiting in the vault for the first start. Once per profile:fingerprint.provenance.realDeviceRef=vision:<id>. Withlocal(A225), its files from this computer too —%APPDATA%/Vision/profiles/<id>.zip, or the live folder when newer; one open in Vision now (itslockfileheld) moves without them: bookmarks, history, site icons, Local/Session Storage, IndexedDB, tabs, saved passwords (Vision keeps them in the clear,K99; sealed here under the profile’s own OS-crypt key, made before its first start — a value Vision’s browser had sealed is not carried and is counted) and its Chrome Web Store extensions (downloaded once per computer byextension.addFromStore, bound to the profile, their data with the files). Not: cookies (the API brings them; both counts go to the journal),Web Data, Vision’s own extensions (their id is a hash of Vision’s path,K104). The read’sdisksays beforehand what will be found.transfer.job({ id }) · transfer.current() · transfer.stop({ id })- CLI:
vflin transfer vision-read --token-file <path>·vision-start --token-file <path> --profiles <id,id> [--local]·current·job <id>·stop <id>— the token from a file, never the command line. MCP: generated from the contract (vflin_transfer_*).
5. Events (push, over the stream)
profile.state_changed, automation.run_progress, sync.progress, core.crash,
policy.violation, proxy.health_changed. Clients subscribe; UI/CLI/MCP render them.
Emitted today (GET /v1/events, JSON with an event tag — the list above is the design, this is the wire):
profile.state_changed {id, state}— a start, a stop, a crash landing.core.crash {id, pid}— a browser that went away uncleanly.app.shell_changed {connected}(A170, ISSUE-468) — a program window connected to the daemon, or the last one went away, with how many are counted now. Only a connected window can run the installer, soapp.update.status.shellConnecteddecides whether a screen offers the in-app update or the browser download; nothing announced the change before, and a window that asked for the status before its own event bridge had connected was toldfalseand kept the browser route until something else made it re-read.profile.start_failed {id, code, message}(A117, ISSUE-290) — a start that failed, in the daemon’s words: the samecodethe call answered. Includes a start whose identity could not be applied (FINGERPRINT_APPLY_FAILED— the browser is closed rather than left open with half an identity).transfer.job_changed {id}(A222) — a transfer moved on: a profile began or ended, or the transfer did; the job istransfer.job.automation.warmup.progress {warmupId, profileId, stage, done, total, url?}(A117, ISSUE-279) — the cookie robot on one profile:stageisqueued·starting·visiting·done·failed·stopped;doneoftotalsites;urlthe page it is on. The same facts are read back byautomation.warmup.list.
6. Versioning
system.health()returnsapiVersion(semver). Breaking changes bump major.- Additive methods/fields are minor. Clients must ignore unknown fields.
7. Hard rules
- No method returns a raw filesystem path that bypasses the lifecycle. The one bridge,
materialize, returns a raw path only to the local UI/owner; CLI/MCP/script clients get an opaque handle, and any raw-path return is power mode + audited (PROFILE_STORAGE_SYNC §6, SECURITY_POLICY §2). - No business logic on the client side of this API. (See MODULE_BOUNDARIES.md §5.)
8. Concrete contract (OpenAPI) — the walking-skeleton surface
The machine-readable source of truth is contract/openapi.yaml (internal: contract/openapi.yaml)
(OpenAPI 3.1). Phase 0 models the 6 walking-skeleton methods end-to-end; the rest of §4 is
promoted into it one method at a time via the local-api-contract-designer skill.
RPC-over-REST mapping. Logical domain.action names map to REST resource paths:
- CRUD → resource:
profile.create→POST /profiles,profile.list→GET /profiles,profile.get→GET /profiles/{id},profile.delete→DELETE /profiles/{id}. - Action on a resource → sub-path:
profile.start→POST /profiles/{id}/start,profile.stop→POST /profiles/{id}/stop. - Singleton/collection reads:
core.list→GET /cores,system.health→GET /system/health. - Base path carries the major version:
/v1.
Envelope + status. Success = HTTP 200 + { ok:true, data }. Failure = a mapped HTTP status +
{ ok:false, error: { code, message } }. contract/openapi.yaml routes every failure through
default: $ref: Error and points here for the status, so the table below is the status contract rather
than a commentary on one.
It is COMPLETE, and it is checked. Every code the daemon can put on the wire is a row, in both
directions: a row naming a code the daemon does not build fails the gate, and a code the daemon builds
with no row fails it too. The gate is
vflin-localapi’s the_status_table_in_the_spec_is_what_the_daemon_returns, and it gets the daemon’s
half by CONSTRUCTING each error and calling into_response() — it reads the answer off the same code
path a client does, rather than pattern-matching the source. A new ApiError variant cannot slip past
either: the compiler refuses one without a code (ApiError::code() has no _ arm), and the gate counts
that function’s arms and requires one sample per arm — because a variant with a code that the gate never
CONSTRUCTS would leave it green over a code the daemon can emit and this table does not list.
The source column says where a code originates: daemon = built locally by ApiError; cloud =
relayed from the cloud backend by map_cloud_err, preserving the upstream status (ISSUE-014) so a wrong
cloud password stays a 401 rather than flattening to a 400; both = the same code and status on either
path.
| code | HTTP | source | when |
|---|---|---|---|
INVALID_ARGUMENT |
400 | both | the request is malformed, or a stated value is not usable |
UNAUTHENTICATED |
401 | both | missing/invalid bearer token; or the cloud rejected the credential |
TOTP_REQUIRED |
401 | cloud | the cloud account has 2FA and no code was supplied |
TOTP_INVALID |
401 | cloud | the supplied 2FA code did not verify |
POLICY_DENIED |
403 | daemon | refused by policy — the actor is not permitted, ADR-0036 |
ACCOUNT_REQUIRED |
403 | daemon | a profile route or a start while the program holds no usable account session — not signed in, or the account server not reached for longer than the offline allowance (A198, ADR-0051); details carries access, lastContactAt, offlineUntil, offlineAllowance. Since A217 (ISSUE-653) every CLOUD action without a session too, with details.signedOut when the cloud ended the session |
DEVICES_FULL |
403 | cloud | A217: a sign-in when every device the plan allows is signed in (cloud 0.0.23); details limit, used, devices {id, name, current, lastSeen, createdAt} – sign in again with replaceDeviceId |
SHARE_NEEDS_PAID_PLAN |
403 | cloud | A217: sharing a profile from a plan that does not open it (details.plan) |
KEYRING_PROGRAM_ONLY |
403 | cloud | A217: the account’s keys are published only by the program on a signed-in device |
FORBIDDEN |
403 | cloud | the cloud refused this actor |
QUOTA_EXCEEDED |
403 | cloud | a plan/seat/storage entitlement limit was reached |
SEATS_FULL |
403 | cloud | A214: a new profile’s place was refused – the plan’s places are taken (details used, limit, plan); with localAllowed the daemon creates it here instead |
CREATIONS_EXHAUSTED |
403 | cloud | A214: the billing period’s creations are spent (details used, ceiling, resetsAt) |
SEAT_IN_CLOUD |
409 | cloud | A214: a place cannot be released while the profile is in the cloud – it leaves through the Trash |
PROFILE_TOO_LARGE |
413 | cloud | A214: a push larger than the cloud takes for one profile (details size, limit); the profile stays here |
PAYLOAD_TOO_LARGE |
413 | cloud | A217: a request body larger than the cloud takes |
NOT_FOUND |
404 | both | the named resource does not exist |
CONFLICT |
409 | both | well-formed and refused because of STATE, not permission |
CORE_NOT_EXITED |
409 | daemon | a browser refused to close, so its data could not be sealed |
SEAL_REFUSED |
409 | daemon | the live data lost a key file its previous sealed copy had (A152) — not sealed, the live folder kept |
PROFILE_KEY_UNAVAILABLE |
409 | daemon | a start found no key that opens the profile’s own cookies and passwords — not this machine’s, not the one its sealed copy carries — or found them under two keys (A153, ADR-0047), or could not record for the account the key it would hand the browser (A154); not started. A store that cannot be READ is not a refusal: the start goes on and says so in storesUnread (A155) |
SETTINGS_DAMAGED |
409 | daemon | a start found Default/Preferences there and unreadable (A152); untouched, a copy kept — profile.start with defaultSettings sets it aside (A153) |
APP_UPDATING |
409 | daemon | a start while app.update.apply has closed the profiles for the installer (A153) |
PROFILE_BUSY |
409 | daemon | a start waited past its deadline for another operation on the same profile; the message names it |
WALLET_NOT_IN_COPY |
409 | daemon | a push refused: the profile’s record names a set-up wallet and the seal of its copy found none of the wallet’s data, so sending it would replace the cloud’s copy that has the wallet (A223); asked again after the next seal |
ALREADY_EXISTS |
409 | cloud | a duplicate the cloud will not create twice |
STALE |
409 | cloud | a version-vector conflict (ADR-0016) |
PROFILE_TRASHED |
409 | cloud | A217: the profile is in the account’s Trash in the cloud |
FAILED_PRECONDITION |
409 | cloud | a cloud precondition is unmet — e.g. rotation exact-coverage |
LEASE_LOST |
409 | cloud | the sync lock was stolen or fenced (ADR-0015) |
LOCKED_REMOTE |
409 | cloud | the sync lock is held by another device |
BLOB_UNREADABLE |
422 | daemon | the stored payload will not open with the key this session holds |
LOCKED |
423 | daemon | the keyring is sealed — unlock before this call can proceed |
RATE_LIMITED |
429 | cloud | back off and retry |
CORE_LAUNCH_FAILED |
500 | daemon | the browser core would not start |
INTERNAL |
500 | daemon | an unexpected failure; the detail is in daemon.log under the id shown |
CLOUD_ERROR |
502 | cloud | the cloud failed in a way with no code of its own |
BLOB_IO |
503 | daemon | a file under the live profile dir could not be read/written — usually transient |
UNAVAILABLE |
503 | both | a dependency is momentarily not ready (including “cloud is unreachable”) |
A CONFLICT whose message names the refusal is NOT a row here (A156, taught by the gate above, which went
red on two rows of exactly that kind). APP_UPDATE_NOT_READY, APP_SHELL_ABSENT, APP_UPDATE_BLOCKED,
APP_UPDATE_PROFILES_NOT_CLOSED, APP_UPDATE_PROFILE_BROKEN and APP_UPDATE_PROFILES_UNREADABLE are prefixes
inside a 409 CONFLICT message, not ApiError variants, so the wire code a client switches on is CONFLICT.
They are documented where the operation is: APP_UPDATE.md (internal: docs/20-control-plane/APP_UPDATE.md). The table stays a map of the codes
the daemon really builds, which is what makes it gateable in both directions.
Two statuses are worth reading twice, because both were wrong here until 2026-08-23. LOCKED_REMOTE
is a 409, not a 423: it is a cloud-relayed code and map_cloud_err preserves the cloud’s own status,
which is what “relay faithfully” means. 423 is LOCKED — the sealed keyring, a different thing
entirely. And CORE_NOT_EXITED is a 409 rather than a 503 (A24, 2026-08-20): the service is healthy
and a precondition is unmet, so inviting the caller back would be a lie.
Cross-cutting headers. Mutating calls send Idempotency-Key and X-Vflin-Mode
(normal|power|danger). Auth = Authorization: Bearer <token> from ~/.vflin/local-api.json.
Lockstep (one contract → three surfaces). Each operation carries x-vflin-* fields so the CLI,
MCP, and SDKs generate from this file (invariant #8; enforced by api-contract-codegen-guard):
domain.action |
REST | CLI | MCP tool | mode | danger |
|---|---|---|---|---|---|
system.health |
GET /system/health |
vflin system health |
vflin_system_health |
normal | 🟢 |
core.list |
GET /cores |
vflin core ls |
vflin_core_list |
normal | 🟢 |
profile.list |
GET /profiles |
vflin profile ls |
vflin_profile_list |
normal | 🟢 |
profile.create |
POST /profiles |
vflin profile create |
vflin_profile_create |
normal | 🟡 |
profile.start |
POST /profiles/{id}/start |
vflin profile start <id> |
vflin_profile_start |
normal | 🟡 |
profile.stop |
POST /profiles/{id}/stop |
vflin profile stop <id> |
vflin_profile_stop |
normal | 🟡 |
profile.session.set |
POST /profiles/{id}/sessions |
vflin profile session set <id> |
vflin_profile_session_set |
danger | 🔴 |
profile.session.clear |
DELETE /profiles/{id}/sessions/{service} |
vflin profile session clear <id> <svc> |
vflin_profile_session_clear |
danger | 🔴 |
This table is an EXCERPT, not the catalogue. It was written when the surface was six walking-skeleton methods; the shipped surface is now far larger and every entry here is hand-maintained, so it rots. The authoritative list is
contract/openapi.yamland its generated projection (vflin surface/crates/vflin-client/src/generated.rs). Note also that the mode axis is live: the wallet and session mutators aredanger, notnormal(docs audit 2026-07-19).
Events stay on the WebSocket /events (§5) — OpenAPI can’t describe WS; an AsyncAPI doc is the
planned home (OPEN_QUESTIONS).
Open questions
Event/streaming schema (AsyncAPI?), auth token lifetime + rotation, request batching — OPEN_QUESTIONS.md.
The token the daemon runs with, and the one the browser gets (A146, ISSUE-428)
system.health carries three fields about this, and they answer different questions:
| field | says |
|---|---|
processToken |
normal / elevated / restricted — what THIS daemon runs with. Not by itself a reason a start fails |
browserToken |
as-us / plain / stuck — what a browser it starts runs with. plain: the daemon is elevated and the browser is given the person’s own filtered token instead, so it is medium-integrity as if nobody had run the program as administrator |
startRefusal |
the exact words profile.start would refuse with right now, ABSENT when it would not refuse |
Why startRefusal is a field and not just an error. The owner ran the installed program as administrator for days;
his journals logged the refusal five times over four days, and nothing said it until he pressed «Старт». A window
should say it when the program STARTS — so the daemon publishes the same sentence profile.start would answer with,
read from the same two functions, and a client shows it as it stands. A present startRefusal is never a reason for a
client to hide the start button: the daemon is the one that refuses, and it says why.
What the refusal is now. Not «the daemon is elevated» — A110’s counter-flag retired that, measured in A146
(an elevated daemon with the override started a profile in 1.0 s and kept it over the pipe). It is «this browser would
have to run with a token it must not, and this machine could not be made to hand it the person’s plain token
instead», and it names the step that failed. A restricted token has no path to a filtered one, so it still refuses.