VFLIN

docs · docs/20-control-plane/LOCAL_API_SPEC.md · synced 2026-11-08

Local API

the localhost HTTP/JSON contract every client speaks; with contract/openapi.yaml

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_FOUND until 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 so generateFromHost / fromHost can derive host-coherent identities (was the standalone capture-profile binary).

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 / list
  • automation.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, GET only (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, polls transfer.job) — each profile made the way profile.create makes 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>. With local (A225), its files from this computer too — %APPDATA%/Vision/profiles/<id>.zip, or the live folder when newer; one open in Vision now (its lockfile held) 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 by extension.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’s disk says 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, so app.update.status.shellConnected decides 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 told false and 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 same code the 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 is transfer.job.
  • automation.warmup.progress {warmupId, profileId, stage, done, total, url?} (A117, ISSUE-279) — the cookie robot on one profile: stage is queued · starting · visiting · done · failed · stopped; done of total sites; url the page it is on. The same facts are read back by automation.warmup.list.

6. Versioning

  • system.health() returns apiVersion (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.yaml and its generated projection (vflin surface / crates/vflin-client/src/generated.rs). Note also that the mode axis is live: the wallet and session mutators are danger, not normal (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.