Host protocol

Host protocol

A host starts a built agent, gives it calls and follows them over this HTTP protocol. Self-hosting covers when you need it.

Curb owns this protocol; nothing in it is specific to any one host. The Zod schemas in src/hosted/protocol.ts are the source. src/hosted/protocol.schema.json is the same contract as one JSON Schema 2020-12 document (every type under $defs) for hosts written in other languages, and src/hosted/protocol-fixtures/{valid,invalid}/<Type>.<case>.json are examples every implementation must accept or refuse.

Runner environment

VariableMeaning
CURB_MANAGEMENT_ADDRhost:port to listen on. Absent means not managed. Port 0 binds a free port; the ready line reports the bound one.
CURB_MANAGEMENT_TOKENPer-runner bearer token. Required when the address is set; without it the runner refuses to open the port. Use one token per runner, never one per fleet.
CURB_MAX_SESSIONSConcurrent calls this runner takes. Default 1. Read once, at boot (see “Several calls in one runner”).
CURB_DEPLOYMENT_IDOptional generation pin: which deployment this runner is.
CURB_CONFIGURATION_VERSIONOptional generation pin, a positive integer.

Provider keys for the agent’s STT, LLM and TTS are ordinary environment variables of the runner process. A start request never carries secrets.

The ready line

Once warm, the runner prints one line on stdout. Hosts may rely on its exact prefix:

curb-hosted {"event":"ready","address":"127.0.0.1:7071","maxSessions":1,"warmupMs":812}

That is curb-hosted, a space, then a LifecycleReady JSON object with event as its first key. Every lifecycle record is also printed this way. A runner whose warmup fails never prints ready and exits non-zero.

Routes

GET /health needs no auth and answers 200 {"ok":true,"protocolVersion":1,"versions":[1]}. Every other route needs Authorization: Bearer <token>, else 401 {"error":"unauthorized"} (checked before anything else, so an unauthenticated caller learns nothing). A path not under /v1/ answers 404 {"error":"unknown_version","detail":...,"versions":[1]}.

RouteSuccessErrors
GET /v1/runner200 RunnerStatus
GET /v1/sessions200 {sessions: LiveCall[]}
GET /v1/sessions/{id}200 LiveCall404 session_not_found
POST /v1/sessions (body StartCallRequest)202 {sessionId, accepted}400 invalid_request, 409 runner_at_capacity, 409 generation_mismatch, 503 runner_draining, 500 start_failed, 501 runner_takes_no_calls
DELETE /v1/sessions/{id}?reason=&graceMs=202 {sessionId, stopped}400 invalid_request
POST /v1/sessions/{id}/messages (body HostMessage)202 {id, delivered}400 invalid_request, 404 session_not_found, 409 runner_at_capacity, 422 no_handler / invalid_payload / handler_failed, 501 runner_takes_no_calls
PATCH /v1/sessions/{id} (body SessionUpdate)200 {sessionId, settings: EffectiveSettings}400 invalid_request, 403 not_adjustable, 404 session_not_found, 422 invalid_settings, 501 runner_takes_no_calls
POST /v1/runner/drain202 {draining: true, activeSessions}
GET /v1/runner/events?from=N200 NDJSON stream400 invalid_request, 410 cursor_too_old, 503 no_journal
anything else404 not_found
a handler failed500 internal

Shapes (all in protocol.schema.json):

  • StartCallRequest = {sessionId, realtimeUrl, realtimeToken, deploymentId?, configurationVersion?, clientContext?, direction?, experiments?}. Strings are non-empty; configurationVersion is a positive integer. Unknown fields are ignored. deploymentId and configurationVersion are each compared only when both the runner and the request state them; a mismatch is 409 generation_mismatch.
  • clientContext is what the caller’s page or app said about this call, one string per key ({"event": "webinar-11-04"}); anything else is 400 invalid_request. The call’s agent reads each entry as client.<key>, set before its session starts, so the greeting can use it (agent guide). Per call, never in the environment. Curb passes it on as sent: your host decides its limits, and since a browser wrote it, never an identity or a permission.
  • direction is outbound when your host dialed the caller, inbound (the default) otherwise; the agent reads it as call.direction.
  • experiments is the variant your host assigned this call in each of the agent’s experiments (the bundle manifest’s experiments), experiment name to variant name ({"greeting": "casual"}), one string per key like clientContext. The agent reads each as experiment.<name> (useVariant(name)), set before its session starts. The runner trusts it and applies it as sent. To assign as Curb does locally (curb dev, curb test): a fromClient key in clientContext whose value names a variant picks it; otherwise the first 8 bytes of SHA-256(<sessionId>:<experiment>), read as a big-endian unsigned 64-bit integer, modulo the sum of the weights, walked down the variants sorted by name, picks the first variant whose weight is larger than what is left.
  • accepted: false means an idempotent repeat of a call the runner already holds.
  • stopped: false means the runner held no such call. Still a success, so a retried stop never turns into an error.
  • graceMs is an integer from 0 to 60000 (default 0): how long to let the agent finish its current utterance before leaving the room.
  • RunnerStatus = {protocolVersion: 1, deploymentId?, configurationVersion?, maxSessions, activeSessions, draining, sessions: LiveCall[]}.
  • LiveCall = {sessionId, state: "starting" | "connected" | "ending" | "ended", startedAtMs}.
  • SessionUpdate and EffectiveSettings: see Live session settings.
  • In dev and worker mode, POST /v1/sessions, GET /v1/runner and POST /v1/runner/drain answer 501 runner_takes_no_calls (see above).

Error codes

Every non-2xx body is ErrorBody = {error, detail?, versions?, requested?, oldestRetained?, path?, issues?}. Classify by error, never by detail, which is prose for humans.

unauthorized, unknown_version, not_found, invalid_request, session_not_found, runner_at_capacity, generation_mismatch, runner_draining, start_failed, runner_takes_no_calls, cursor_too_old, no_journal, internal, no_handler, invalid_payload, handler_failed, not_adjustable, invalid_settings.

runner_draining (503) means “place this call on another runner”; runner_at_capacity and generation_mismatch (409) mean “this runner cannot take it”. Runners older than protocol v1 answered some 404s with prose in error; treat any 404 on POST /v1/sessions as a protocol-version mismatch. no_handler, invalid_payload and handler_failed (422) mean the call is there and its agent refused that message (see “App messages”); invalid_payload carries the schema’s issues as [{path, message}]. not_adjustable (403, with path) and invalid_settings (422, with issues) refuse a settings change as a whole (see “Live session settings”).

Event stream

GET /v1/runner/events?from=N streams everything the runner has to say as NDJSON (content-type: application/x-ndjson), one JournalRecord per line:

{"envelope":1,"producer":"runner","seq":1,"class":"lifecycle","at":1790625584758,"body":{"event":"ready","address":"127.0.0.1:7071","maxSessions":1,"warmupMs":185}}
  • envelope is runner-wide, gapless and positive: the resume cursor. from is exclusive, so reconnect with the last envelope you processed; from=0 replays everything still held.
  • producer + seq identify a record within its producer, for deduplication and for spotting a record lost before it reached the stream.
  • at is milliseconds since the Unix epoch.

Record classes:

classproducerbody
conversationthe session id{type: "turn", seq, at, speaker: "caller" | "agent", text, final} or {type: "event", seq, at, kind: "curb.<name>", durationMs?, payload}
lifecyclerunner{event, ...}: ready{address, maxSessions, warmupMs}, call-started{session, room}, call-stopping{session, reason}, first-audio{session, monotonicMs, sinceStartMs}, draining{signal?, active}, ended{reason}
hostthe session id{type: "host", seq, at, id, name, payload, expectsReply, inReplyTo?}, a message from the agent to its host

What a call’s record contains. Besides the turns and the host records, every call that keeps a record (a managed or direct-join runner, curb dev / curb start with a management port) writes these conversation events. A plain self-hosted call with no record writes none of them.

kindWhenpayload
curb.message_to_clientevery useSend().toClient message that left for the caller’s app{key, id, payload, truncated?}
curb.reply_to_clientevery reply the agent gave to the app’s curb.message RPC (an ask){key, id, reply, truncated?}
curb.messagesonce, just before curb.ended, only when something is non-zero{undeliveredToClient: {[key]: n}, refusedQueuedHostMessages: n}
curb.endedlast{reason}

payload and reply are kept as they were sent while their JSON is at most 4 KiB; a larger one is kept as the first 4 KiB of its JSON text, as a string, with truncated: true. undeliveredToClient counts toClient messages no app received (a phone call, or no app in the room yet), per key; refusedQueuedHostMessages counts host messages accepted while the call was starting that the agent refused once it connected.

A reader MUST skip classes it does not know (older runners also emitted log) and should count what it skipped. Diagnostics go to the runner’s stdout and stderr, never the stream.

Heartbeat. An idle stream writes {"ping": <epoch ms>} every 15 seconds. It has no envelope, is not a record, and never advances a cursor. Set your read deadline to a comfortable multiple of 15 seconds.

Resume and 410. The runner retains a bounded window of records (4096 by default). A cursor older than the window answers 410 {"error":"cursor_too_old","requested":N,"oldestRetained":M} rather than silently skipping the gap. Reconnect without a cursor to follow from now on, and record the envelopes between requested and oldestRetained as lost.

Truncation rule. A call’s conversation is complete when its producer’s last record is the event curb.ended (payload {reason}); EOF without it means the stream was cut. The runner’s own terminal record is lifecycle ended, written before the listener closes.

Validating a host implementation

Decode every answer and every stream line against the matching $defs type of protocol.schema.json, and run your decoder over the fixtures: each file under protocol-fixtures/valid/ must decode as the type its name starts with, and each file under protocol-fixtures/invalid/ must be refused. The schema never forbids unknown properties; keep your decoder that lenient, so a newer runner’s additions do not break it.

The Curb repository runs this end to end on every change (npm run smoke:self-host): it builds an example agent, starts it as a managed runner with no LiveKit credentials, and checks every answer against the schema.

curl walkthrough

ADDR=127.0.0.1:7071
TOKEN=...   # the runner's CURB_MANAGEMENT_TOKEN
auth=(-H "Authorization: Bearer $TOKEN")

curl -s http://$ADDR/health
curl -s "${auth[@]}" http://$ADDR/v1/runner

# Follow the stream in another terminal (-N: no buffering).
curl -sN "${auth[@]}" "http://$ADDR/v1/runner/events?from=0"

# Place a call: create the LiveKit room and a room-scoped token first. clientContext is
# optional; the agent reads it as client.event.
curl -s "${auth[@]}" -H 'content-type: application/json' \
  -d '{"sessionId":"call-42","realtimeUrl":"wss://livekit.example.com","realtimeToken":"<room token>","clientContext":{"event":"webinar-11-04"}}' \
  http://$ADDR/v1/sessions

curl -s "${auth[@]}" http://$ADDR/v1/sessions/call-42
curl -s "${auth[@]}" -X DELETE "http://$ADDR/v1/sessions/call-42?reason=caller_hung_up&graceMs=2000"

# Retire the runner: finish the calls in progress, take no new ones, then exit.
curl -s "${auth[@]}" -X POST http://$ADDR/v1/runner/drain

SIGTERM or SIGINT drains the same way (the draining record then names the signal); a second signal exits at once.

App messages

Agent code exchanges small keyed messages with two parties during a call: the client (the app the caller uses, over LiveKit) and the host (whatever runs the call, over this protocol). A key is any non-empty string of at most 256 characters, used verbatim on the wire. Dotted names such as human.requested remain a good convention for a host’s own vocabulary. The transport stamps who sent a message, never the payload.

import { Tool, useMessage, useSend, z, HostTimeoutError, HostUnavailableError } from "curb";

function Claims() {
  const send = useSend();

  // From the caller's app. The return value is the reply to the client's send.
  useMessage("claim-submitted", {
    from: "client",
    schema: z.object({ claimId: z.string() }),
    onMessage: ({ claimId }) => ({ received: claimId }),
  });

  // From the host, unprompted.
  useMessage("ops.notice", { from: "host", onMessage: (payload) => console.log(payload) });

  async function askSupervisor(topic: string) {
    send.toClient("show-status", { status: "waiting-for-supervisor" });
    try {
      const answer = await send.askHost("human.requested", { topic }, { timeoutMs: 10_000 });
      return answer.disposition === "allowed" ? "Approved." : "Declined.";
    } catch (error) {
      if (error instanceof HostTimeoutError) return "The supervisor did not answer in time.";
      if (error instanceof HostUnavailableError) return "No supervisor is connected to this call.";
      throw error;
    }
  }
  // ...render a <Tool> that calls askSupervisor
}
  • useSend() returns { toClient(key, payload?), toHost(key, payload?), askHost(key, payload?, { timeoutMs? }) }. Payloads are JSON objects. It is stable across renders and safe in tool handlers.
  • toClient with no app in the room yet, toClient on a phone call (SIP only, so no data channel), and toHost with no host connected are dropped with one warning per key and reason, never thrown. A dropped toClient is counted in the call record (curb.messages). The caller’s app can call the agent as soon as the agent joins the room: a request that arrives before the agent’s runtime is ready waits for it, up to 10 seconds, then is refused no_handler (not_adjustable for an update).
  • askHost resolves with the payload of the host message whose inReplyTo is the ask’s id. It rejects with HostUnavailableError when no host is connected or the call ends while it waits, and with HostTimeoutError after timeoutMs (default 10000). A host is connected when the call keeps a record a host reads: a managed runner, a direct-join runner serving its events stream, or curb dev / curb start with a management port. Without one (and in unit tests) there is none.
  • useMessage(key, { from, schema?, onMessage }) handles every message with that key from "client" or "host" while the component is mounted. A from: "client" handler never sees a host message and vice versa. With a Zod schema, onMessage gets the parsed payload; without one the payload is unknown. If several mounted components listen for one key, each runs in mount order and the reply is the most recently mounted handler’s return value. After the handlers run the context is recompiled, so a handler that changes a store can unlock a skill or tool.
  • A message is refused with a code, never dropped: no_handler (nothing listens for the key from that sender), invalid_payload (a schema refused it; no handler ran, the Zod issues are returned), handler_failed (a handler threw; the others still ran).
  • An invalid key or from throws AGENT_INVALID_AUTHORING_PROP.
  • Each call has its own messages: two calls on one runner never see each other’s.

Host side

HostMessage = {id, name, payload, inReplyTo?}:

  • id: 1 to 128 characters, unique per call. Delivery is idempotent by id.
  • name: the message key, 1 to 256 characters.
  • payload: a JSON object.
  • inReplyTo: the id of the agent’s askHost this message answers.

Host to agent. POST /v1/sessions/{id}/messages with a HostMessage body answers once the agent’s handlers have run: 202 {"id":..., "delivered":true}, or "delivered":false when that id was already delivered to the call. A reply (inReplyTo naming an ask still waiting) resolves that ask and needs no handler; a reply that arrives after the timeout is handled like any other message. A refusal answers 422 with no_handler, invalid_payload (with issues) or handler_failed, and its id is not taken, so a corrected retry with the same id is delivered. An unknown or ended call is 404 session_not_found, a malformed body 400 invalid_request. Ids are scoped to the call: two calls may each take a message m-1.

A message to a call still starting is held and delivered, in order, once the call connects. Up to 32 are held per call; past that the message is refused with 409 runner_at_capacity and its id is not taken, so retry it once the call is connected. A held message was already answered 202, so if the agent refuses it on delivery the runner logs a warning and counts it instead. A direct-join runner (handed its one call at start) answers 501 runner_takes_no_calls on this route: it has no host-to-agent path, so an askHost there times out, but its agent-to-host records still leave over its events stream.

curl -s "${auth[@]}" -H 'content-type: application/json' \
  -d '{"id":"m-7","name":"human.decision","payload":{"disposition":"allowed","reason":"verified"},"inReplyTo":"a-3"}' \
  http://$ADDR/v1/sessions/call-42/messages

A refusal:

{"error":"invalid_payload","detail":"invalid_payload: the payload of \"lead\" does not match its schema: score: Invalid input: expected number, received string","issues":[{"path":["score"],"message":"Invalid input: expected number, received string"}]}

Agent to host. A record of class host on the events stream, producer the session id, seq from the same per-call counter as the conversation:

{"envelope":57,"producer":"call-42","seq":31,"class":"host","at":1790625600000,"body":{"type":"host","seq":31,"at":1790625600000,"id":"a-3","name":"human.requested","payload":{"topic":"refund"},"expectsReply":true}}

expectsReply: true (from askHost) means the agent is waiting: answer with a HostMessage whose inReplyTo is the record’s id. toHost records have expectsReply: false. Which keys exist and what their payloads mean is agreed between your agent and your host; Curb only carries them.

Transfer on your own host

useTransferCall and <TransferCallTool> work over these messages, so a host that can dial can offer transfers. The transfer asks with askHost, so it appears on the events stream as a host record with expectsReply:

DirectionNamePayload
Agent to hostcall.transfer{ to, ringTimeoutSeconds? }
Host to agent, as the replycall.transfer.accepted{ transferId }
Host to agent, as the replycall.transfer.refused{ code, message }, passed to the agent as the outcome refused
Host to agent, once the target answered or the dial failedcall.transfer.result{ transferId, outcome, dialStatus }, with outcome one of answered, no_answer, busy, failed. Not a reply: it has no inReplyTo

Both replies set inReplyTo to the record’s id.

Dial the target into the call’s LiveKit room. When it joins, the agent stops listening and speaking; when it leaves and the caller is still there, the agent takes the call back. A host that sends no reply in time gives the agent the outcome failed, with the code transfer_no_reply. A direct-join runner takes no host messages, so a transfer there ends the same way.

Client side

The wire format is plain LiveKit, so livekit-client and the native SDKs work without a Curb library. All of it, both parties:

DirectionTransportShape
agent to clienttext stream, topic curb.message{key, id, payload}
client to agent (send and ask)RPC method curb.message on the agent participantrequest {key, id, payload}; response {reply?}, or an RPC error
client to agent, settingsRPC method curb.update on the agent participantrequest a SessionUpdate; response the EffectiveSettings, or an RPC error
agent to hosthost protocol v1host records on GET /v1/runner/events
host to agenthost protocol v1POST /v1/sessions/{id}/messages
host to agent, settingshost protocol v1PATCH /v1/sessions/{id}, the same body as curb.update

Client to agent is RPC rather than a text stream so every send gets an acknowledgement or a coded error. The cost is LiveKit’s 15 KiB RPC payload cap.

A refused RPC fails with an RPC error whose message is the code:

CodemessageMeaning
2001no_handlernothing listens for the key with from: "client"
2002invalid_payloadthe envelope is malformed or the schema refused the payload; data is the reason, or {"issues":[...]}
2003handler_faileda handler threw (its error stays on the server)
2004payload_too_largethe request is over LiveKit’s 15 KiB RPC cap
2005not_adjustablecurb.update touched a path <Agent adjustable> does not give the client; data is {"path":...}
2006invalid_settingscurb.update is malformed or a value does not fit; data is {"issues":[...]}

curb/client

curb/client wraps that wire format for a browser app. It depends only on livekit-client and takes the Room your app already owns, including useRoomContext() from @livekit/components-react:

import type { Room } from "livekit-client";
import { AgentRefusedError, connectAgent } from "curb/client";

export async function talkToAgent(room: Room, openForm: (form: unknown) => void) {
  const agent = connectAgent(room);                          // one per room
  agent.on("show-form", (payload) => openForm(payload));     // useSend().toClient("show-form", …)

  await agent.send("claim-submitted", { claimId: "C-17" });  // resolves once the agent took it
  const quote = await agent.ask<{ price: number }>("quote", { plan: "basic" }, { timeoutMs: 5000 });

  let voiceChangeable = true;
  try {
    await agent.update({ tts: { voice: 17 } });   // needs <Agent adjustable={{ client: ["tts.voice"] }}>
  } catch (error) {
    if (error instanceof AgentRefusedError && error.code === "not_adjustable") voiceChangeable = false;
    else throw error;
  }

  return { quote, voiceChangeable, close: () => agent.close() };  // close on unmount
}
import type { Room } from "livekit-client";
import type { EffectiveSettings, SessionUpdate } from "curb/client";

declare function connectAgent(room: Room, options?: { agentIdentity?: string }): AgentClient;

interface AgentClient {
  send(key: string, payload?: unknown): Promise<void>;
  ask<T = unknown>(key: string, payload?: unknown, opts?: { timeoutMs?: number }): Promise<T>;
  on(key: string, handler: (payload: unknown, meta: { id: string }) => void): () => void;
  update(settings: SessionUpdate): Promise<EffectiveSettings>;
  close(): void;
}
  • The agent is options.agentIdentity, else the first remote participant of kind AGENT. A call made before the agent joined waits for it, up to its timeout (default 10 s), then rejects AgentUnavailableError.
  • A refusal rejects AgentRefusedError with code (the names above), and issues or path when the agent sent them. A request over 15 KiB is refused payload_too_large before it is sent. Transport failures (a timeout, a disconnect) reject with LiveKit’s own RpcError.
  • on handlers get only the agent participant’s messages. A message with no handler is dropped with one console warning per key.
  • One client per room: LiveKit takes one handler per text-stream topic. close() releases it.

The same exchange with livekit-client alone:

// livekit-client, in the caller's app
import type { Room } from "livekit-client";

export async function talkToAgent(room: Room, showForm: (form: unknown) => void) {
  room.registerTextStreamHandler("curb.message", async (reader) => {
    const { key, payload } = JSON.parse(await reader.readAll());
    if (key === "show-form") showForm(payload);
  });

  const agent = [...room.remoteParticipants.values()].find((p) => p.isAgent)!;
  const response = await room.localParticipant.performRpc({
    destinationIdentity: agent.identity,
    method: "curb.message",
    payload: JSON.stringify({ key: "claim-submitted", id: crypto.randomUUID(), payload: { claimId: "C-1" } }),
  });
  const { reply } = JSON.parse(response);
  return reply;
}

Live session settings

An agent can let its client app and its host change some of its settings during a call: the voice, the TTS model, the recognition language, the LLM. Nothing is adjustable unless the agent says so, per origin:

import { Agent, useSession } from "curb";
import { kugelaudio } from "curb/providers";

export function App() {
  return (
    <Agent
      llm={kugelaudio.llm()}
      tts={kugelaudio.tts({ voice: 1072 })}
      adjustable={{ client: ["tts.voice"], host: ["tts.voice", "stt.language", "llm"] }}
    >
      {/* ... */}
    </Agent>
  );
}

Adjustable paths: tts.voice, tts.model, stt.language, llm. The body is the same from both sides, SessionUpdate:

type SessionUpdate = {
  tts?: { voice?: string | number; model?: string };
  stt?: { language?: string };
  llm?: { provider: "kugelaudio" | "anthropic" | "openai" | "openai-compatible"; model: string; baseURL?: string };
};
  • Deny by default, all or nothing. Every path in the body must be listed for the sender’s origin (a client’s curb.update is client, the host’s PATCH is host), or the whole update is refused not_adjustable naming the first path. A value that does not fit the provider it would reach (a non-numeric KugelAudio voice, a language for a recognizer that takes none, an unknown LLM provider, an unknown key) refuses the whole update invalid_settings with issues. A refused update changes nothing.
  • An override, not a new prop. An accepted value sits above the <Agent> and active <Skill> props until the next outside change of the same path. tts.voice changes the voice of whichever TTS the props pick; switching skills keeps it.
  • Takes effect from the next reply. Speech providers are rebuilt and handed over through LiveKit (AgentSession.updateAgent), so the reply being spoken finishes in the old voice. The LLM changes from the next turn.
  • The endpoint stays the author’s. A client may switch llm only to a provider with no baseURL or to the agent’s own openaiCompatible endpoint, because the endpoint receives the agent’s key. The host may name any http(s) baseURL.
  • The answer is the effective settings, never a credential:
{"tts":{"provider":"kugelaudio","voice":17},"stt":{"provider":"azure","language":"de"},"llm":{"provider":"kugelaudio","model":"qwen3.8-27b"}}

Agent code reads the effective settings with useSession(); a component re-renders when they change:

import { Text, useSession } from "curb";

function VoiceNote() {
  const { settings } = useSession();
  return settings.tts.provider === "kugelaudio" && settings.tts.options.voice === 17
    ? <Text>The caller picked the calm voice; keep sentences short.</Text>
    : null;
}

From the host:

curl -s "${auth[@]}" -X PATCH -H 'content-type: application/json' \
  -d '{"tts":{"voice":17},"stt":{"language":"de"}}' \
  http://$ADDR/v1/sessions/call-42
# 200 {"sessionId":"call-42","settings":{...}}
# 403 {"error":"not_adjustable","detail":"...","path":"stt.language"}
# 422 {"error":"invalid_settings","detail":"...","issues":[{"path":["tts","voice"],"message":"..."}]}

From the client: agent.update(...) in curb/client above, or the curb.update RPC with livekit-client. A change to a call that is still starting is applied once it connects.

Next steps