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 })
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.
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 |
FORBIDDEN |
403 | cloud | the cloud refused this actor |
QUOTA_EXCEEDED |
403 | cloud | a plan/seat/storage entitlement limit was reached |
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 |
ALREADY_EXISTS |
409 | cloud | a duplicate the cloud will not create twice |
STALE |
409 | cloud | a version-vector conflict (ADR-0016) |
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”) |
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.