App messages
An agent can talk to the page the caller is on, and to whatever hosts the call, without the model being involved.
useSend() sends small keyed messages to the caller’s app (toClient) or to whatever
hosts the call (toHost, askHost); useMessage(key, { from, schema?, onMessage })
handles what they send. A key is any non-empty string up to 256 characters.
import { useState } from "react";
import { HostTimeoutError, HostUnavailableError, Text, Tool, useMessage, useSend, z } from "curb";
export function Claims() {
const send = useSend();
const [claimId, setClaimId] = useState<string | null>(null);
const [supervisorOnline, setSupervisorOnline] = useState(false);
// From the caller's app. The return value is the reply to the app's send.
useMessage("claim-submitted", {
from: "client",
schema: z.object({ claimId: z.string() }),
onMessage: ({ claimId }) => {
setClaimId(claimId);
return { received: true };
},
});
// From the host.
useMessage("ops.supervisor_online", { from: "host", onMessage: () => setSupervisorOnline(true) });
return (
<>
{claimId !== null && <Text>{`The caller submitted claim ${claimId}.`}</Text>}
{supervisorOnline && (
<Tool
name="ask_supervisor"
description="Ask a human supervisor to approve a refund."
onCall={async () => {
send.toClient("show-status", { status: "waiting-for-supervisor" });
try {
const answer = await send.askHost("human.requested", { topic: "refund" });
return { ok: true, data: answer };
} catch (error) {
if (error instanceof HostUnavailableError || error instanceof HostTimeoutError) {
return { ok: false, message: "No supervisor is available. Offer a callback instead." };
}
throw error;
}
}}
/>
)}
</>
);
}
fromis required: a"client"handler never sees a host message and vice versa.- With a
schema,onMessagegets the parsed payload; a payload it refuses never reachesonMessageand the sender getsinvalid_payloadwith the issues. - A message nobody listens for is refused
no_handler; a handler that throws ishandler_failed. Nothing is dropped silently. toClientwith no app in the room yet,toClienton a phone call (no app), andtoHostwith no host are dropped with one warning per key and reason, and counted in the call record.askHostrejectsHostUnavailableErrorwith no host, andHostTimeoutErrorafter{ timeoutMs }(default 10000).- After a
useMessagehandler runs the context is recompiled, so a store change it makes can unlock a skill or tool, like any other gate. - Each call has its own messages. The wire formats (host protocol and LiveKit) are in Host protocol.
Ask for feedback at the end of a call
Make the question a tool. When the caller answers, the agent calls the tool, and the
tool sends the answer to KugelAudio with send.toHost. The answer is kept with the call
as its data.
In src/agent/agent.tsx, add a component that renders the tool in <GlobalTools>, and
put <Feedback /> inside <Agent>, beside <Skills>, so that the tool exists wherever
the conversation ends. GlobalTools, Tool, useSend and z come from "curb".
function Feedback() {
const send = useSend();
return (
<GlobalTools>
<Tool
name="record_feedback"
description="Record the caller's answer to the feedback question at the end of the call."
schema={z.object({
rating: z
.number()
.int()
.min(1, "Ask for a number from 1 to 5.")
.max(5, "Ask for a number from 1 to 5.")
.describe("The caller's rating, from 1 (poor) to 5 (excellent)."),
comment: z
.string()
.max(500)
.optional()
.describe("Anything else the caller said about the call, in their words."),
})}
success="Feedback recorded."
onSubmit={(input) => {
send.toHost("feedback", input);
return { ok: true };
}}
/>
</GlobalTools>
);
}
send.toHost("feedback", input) sends the rating and the comment under the name
feedback. It doesn’t wait for a reply, and KugelAudio never replies, so use toHost
rather than askHost.
Then tell the agent when to ask, in its <SystemPrompt>:
When the caller has what they called for, ask: "Before you go, how would you rate this
call from 1 to 5?" Call record_feedback with the answer, then say goodbye and end the
call. If the caller does not want to answer, end the call without asking again.
Open a call on the agent’s Calls page: its Data tab shows feedback with the
rating and the comment. If the agent sends feedback more than once in a call, the tab
shows the latest answer, and lists every send with its time in the call under
Sent N times.
npm run devhas no host, so there the message is dropped and the terminal shows a warning. To see the answer on the Data tab, deploy the agent and call it from its Playground in the dashboard.- One message carries at most 16 KB of data. A larger one is not cut short: the Data tab says it was not kept, and none of it is stored.
- A call that starts while the agent keeps nothing keeps none of what the agent sends either (see the recording notice).
- Never send secrets, passwords or card numbers. What the agent sends is stored with the call, like what the caller says.
To keep every answer in a system of your own as well, send it there from onSubmit, as
in the next recipe.
Next steps
- Host protocol: the wire format.