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.