The contract
The contract is the single source of truth, imported by both server and client. It has two axes — role (outer) and direction (inner) — and each entry's shape picks an interaction flavor.
import { z } from 'zod'
import { defineContract } from '@super-line/core'
export const api = defineContract({
shared: { // every role inherits these
clientToServer: { /* requests */ },
serverToClient: { /* events + topics */ },
},
roles: { // each role sees shared ∪ its own block
user: { clientToServer: {…}, serverToClient: {…} },
agent: { clientToServer: {…}, serverToClient: {…} },
},
serverToServer: { /* node ↔ node */ }, // optional, not role-scoped
})Direction
Within shared and each role block there are two directions:
clientToServer— requests the client may call.serverToClient— events and topics the client may receive.
Direction is encoded as named keys, never positional generics — you can't accidentally swap them, and there's nothing to keep in sync between the two sides.
The five flavors
| Flavor | Contract entry | Who initiates |
|---|---|---|
| request | clientToServer: { input, output } | client calls, awaits one reply |
| event | serverToClient: { payload } | server pushes to recipients it picks |
| topic | serverToClient: { payload, subscribe: true } | client subscribes; server publishes |
| room | server API (srv.room(...)) | server controls membership; broadcasts a shared event |
| serverToServer | serverToServer: { schema } | a server node, to its peers |
A serverToClient entry is an event by default; adding subscribe: true turns it into a topic the client opts into. (Topics fold into serverToClient so there's just one axis to learn.)
Roles
Each role is an audience with its own surface. The effective surface for a role is shared ∪ roles[R] — for both requests and events/topics. A user and an agent can have entirely different verbs:
roles: {
user: { clientToServer: { say: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } } },
agent: { clientToServer: { announce: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } } },
}- Type-level: a client created with
role: 'agent'only sees the agent surface;agent.say(...)is a compile error. - Runtime: the server resolves the role in
authenticateand rejects any call outsideshared ∪ roles[role]withNOT_FOUND. The role is a real security boundary, not just a typing convenience.
See Roles & auth for how the role is resolved and verified.
Schemas
Any Standard Schema validator works — Zod, Valibot, ArkType. The examples use Zod. The same schema both types the payload and validates it at runtime.
Next: Requests.