API conventions

One protobuf module, buf.build/spooky-labs/api, package spookylabs.v1, is the single source of truth for every type and every RPC the platform speaks. Nothing anywhere hand-writes a wire type, and no service invents a field that does not exist there first.

The endpoint is https://api.spookylabs.ai.

Transport is Connect

connect-go serves Connect, gRPC and gRPC-Web from one handler on one port, so a browser talks over fetch and a native client gets real gRPC from the same endpoint. TLS terminates at the GKE Gateway; the pod behind it speaks cleartext HTTP/2.

Generation is pinned to the v2 stack — @bufbuild/protobuf v2 and Connect-ES v2 — because that is what the frontends are written against: schema objects and create / fromBinary / toJson free functions, not generated classes.

That means one TypeScript plugin, not two. protoc-gen-es v2 emits the service descriptors alongside the message schemas, and Connect-ES v2 consumes them directly:

const client = createClient(SessionService, transport);

@connectrpc/protoc-gen-connect-es was retired in v2 as redundant and stops at 1.7.0, which hard-pins the v1 runtime. So there is no *_connect.ts file anywhere, and no createPromiseClient.

Authentication

Every RPC on every service authenticates with a Firebase ID token:

Authorization: Bearer <Firebase ID token>

The token is obtained in the browser with signInWithPopup(new GoogleAuthProvider()). There is no token-exchange endpoint and no server-minted custom token. Authorization lives in the API, not in the token — see Tenant isolation.

Both SPAs retry exactly once on unauthenticated, with a force-refreshed token, and any other client should do the same. A second authentication failure must remain visible and trigger reauthentication rather than an unbounded retry.

The tenant comes from the principal

On the tenant-facing services no request carries a namespace or a tenant id. agent_id is a bare name and a payment method is only ever the caller’s own, so one tenant cannot name another’s agent or card.

Only AdminService takes a tenant_id, and it re-checks the caller against the server’s allowlist on every call regardless of what the client believes. IdentityService.WhoAmI.is_admin is a hint for the UI, not the enforcement point.

Streaming

Every stream is server-streaming; there is no client-streaming or bidirectional RPC in the module. SendMessage is unary and distinguishes admission from completion. Preserve an operation identity across retries when acceptance is ambiguous. Streams reconcile persisted task projections; they do not guarantee replay of every transient frame. See Streaming for cursor, checkpoint and terminal-error handling.

Money is integer micro-dollars

Every monetary field is int64 micros (1e-6 USD). Markups are basis points as int32, never a float multiplier, because a float reintroduces the drift integer micros exist to prevent. The only doubles on the wire are ratios that are not money, such as an uptime ratio or an unrealized P/L percentage.

Brokerage quantities are the broker’s own decimal strings, so nothing is rounded on the way through.

A money figure the server does not have is absent, never 0. An unknown balance must not render as an empty account, and an unpriced category is omitted from a bill rather than billed at zero. In TypeScript, int64 arrives as bigint; mixing a bigint with a number throws at runtime where a type-checker cannot see it, so each consumer funnels the conversion through one seam.

Pagination is cursor-based

PageRequest.page_size defaults to 50 and is clamped, not rejected, at 500. page_token is opaque; the PageToken message documents what servers encode into it but is never sent on the wire. The backing stores — the Kubernetes API, Firestore, kagent’s Postgres — offer no stable OFFSET.

Errors

Connect codes, used consistently:

Code Means
InvalidArgument The caller’s input. Includes a cross-namespace modelConfig, an unknown skill name, and invalid user labels
NotFound The object does not exist — or is not the caller’s. A payment method that is not yours answers NotFound, never PermissionDenied, which would confirm the id exists
AlreadyExists Name collision
Aborted A stale etag on UpdateAgent. The etag carries the custom resource’s resourceVersion; an empty etag means overwrite unconditionally
FailedPrecondition The tenant is suspended, or there is no card on file, or required payment standing has not been verified
PermissionDenied Not on the admin allowlist
Unimplemented Declared in the contract, not served yet. See Availability and limits
Internal Everything unrecognised, with a generic message. Kubernetes API errors can carry object details a tenant should not see

An RPC path outside the contract entirely answers a 501 with a Connect error body.

The one field with a security rule on it

ModelRef.name must never contain /. kagent’s ModelConfig permits <namespace>/<name> cross-namespace references and, unlike Agent and RemoteMCPServer, has no allowedNamespaces gate. Rejecting / on write is what stops one tenant from referencing another’s model credentials. The comment on that field in the protobufs is a security requirement, not documentation.

Compatibility

buf breaking runs with the WIRE_JSON ruleset against main. Adding fields, messages and RPCs is safe; renaming or renumbering a field or an enum value is not.

WIRE_JSON rather than FILE because every client of this contract is ours and is regenerated from the same tree in the same change set, so the source-level rules FILE adds only forbid deleting surface that nothing implements or calls. WIRE_JSON still forbids reusing a field number or name once deleted, which is the guarantee that matters for stored data and in-flight clients.

Consequently an RPC, service or message that nothing implements or calls may be deleted outright — BillingService’s hosted-page RPCs went that way once no client called them. A field or an enum value may only go with reserved <number>; reserved "<name>"; in its enclosing type, so the tag can never be reused.

Generated code is vendored, not fetched

gen/ in the api repository is a build artifact and is gitignored. Each consumer vendors its own copy, so every build is hermetic and no build needs private-module authentication:

Consumer How
platform-api make sync-api copies go.mod and gen/go into third_party/api; a replace directive points the module there
web, admin cp -R ../api/gen/ts/spookylabs src/lib/gen/, committed

Regenerate in api, sync into each consumer, and commit the result in the same change set. Never edit generated code by hand.