After-call code
afterCall and afterEvaluation are two functions in your agent’s code that run once a call is
over. Use them to update a CRM, send a summary or file a ticket. They live in your project, deploy
with your agent and read the same variables.
Add a hook
Add one or both to defineAgent in src/agent/agent.tsx:
import { defineAgent } from "curb";
export const agent = defineAgent({
app: BookingAgent,
goals: {
booked: { description: "The caller booked a slot", tool: "book_slot" },
},
// Right after the call: the transcript and the call data are final.
afterCall: async (call) => {
console.log(`Call ${call.id} took ${call.duration_seconds} s`);
},
// Once the goals are judged: the same call, with the results.
afterEvaluation: async (call) => {
const booked = call.goals?.find((goal) => goal.name === "booked");
console.log(`Booked: ${booked?.status}`);
},
});
| Hook | Runs | call.goals |
|---|---|---|
afterCall | Right after the call, on call.ended | null |
afterEvaluation | Once the goals are judged, on call.evaluated | An array, [] when the agent has no goals |
Both run for every call, including one that failed, so check call.status when that matters. A
hook runs apart from the call: nothing it does reaches the caller, and a hook that fails does not
affect the call.
A hook is a plain async function, not a React component, so it cannot use useVariant or any
other React hook.
What a hook receives
A hook is called with (call, event). event is the whole event, the same JSON a webhook receives,
and call is its data.object. Field names are snake_case and times are Unix seconds.
| Field | Meaning |
|---|---|
event.id | Stays the same every time this event is run. Use it to do the work once |
event.type | call.ended for afterCall, call.evaluated for afterEvaluation |
call.id | The call |
call.status | completed or failed |
call.livemode | false for a test call |
call.direction, call.channel | How the call came in, such as inbound and sip |
call.started_at, call.ended_at, call.duration_seconds | When it ran, null for a call that never started |
call.client | What the caller’s page passed to the call, {} when nothing |
call.experiments | The variant of each experiment, {} when none |
call.transcript | { complete, turns }, where each turn has role, text and created |
call.call_data | What the agent sent with send.toHost, as { name, payload }. payload is null for a message too large to keep |
call.goals | Each goal’s name, status and detail. status is reached, not_reached, not_judged or not_evaluable |
call.transcript is null when the call stores nothing, and then call.content is not_stored
and call.call_data is []. It is also null for a transcript too long to send, and then
call.transcript_truncated is true.
The types come from curb, for a hook in a file of its own:
import type { CallHook } from "curb";
export const logCall: CallHook = async (call, event) => {
console.log(event.type, call.id);
};
Do the work once
A hook can run more than once for the same event: a run that failed is run again, and in rare
cases a run that worked is too. Key the work on event.id, so the second run changes nothing:
afterCall: async (call, event) => {
const response = await fetch(`${process.env.CRM_URL}/calls`, {
method: "POST",
headers: { "idempotency-key": event.id, "content-type": "application/json" },
body: JSON.stringify({ id: call.id, status: call.status }),
});
if (!response.ok) throw new Error(`The CRM answered HTTP ${response.status}`);
},
Throw when the work failed, so the run counts as failed and can be tried again.
Time, retries and order
A run has 120 seconds. Past that it fails with timeout. A throw fails it too, with the thrown
error’s message, which holds no stack trace and no value of the runner’s environment.
On KugelAudio a run gets 3 attempts, the second after 30 seconds and the third 5 minutes later, and
a call’s afterEvaluation starts after its afterCall has finished. There is no order between
different calls. After-call code needs a plan that includes it: on any other plan a hook does not
run, and its error says so.
On your own servers, your host decides when a hook runs and how often it is retried: see Hook runner.
Read your variables
A hook reads the agent’s variables from process.env, as a tool does. Add the ones it needs to
curb.requiredRuntimeSecrets in package.json, so a forgotten one fails the deploy rather than a
run.
Try it locally
curb dev and curb say never fire a hook, so a test conversation cannot write to your real CRM.
Run one yourself on a call you describe:
npx curb hook afterCall --call fixtures/call.json
The fixture can be a whole event, or only the fields your hook reads, and Curb fills in the rest:
{ "status": "failed" }
The hook gets your .env, so point it at a test system. The last line says how the run ended, and
the exit code is 0 when it succeeded and 1 when it threw. To check a hook in a test of your own, use
runHook.
Next steps
- Testing:
curb hookandrunHook. - Goals and experiments: what
afterEvaluationreads. - Self-hosting: run hooks from your own host.