examples/nosh.
Who asked for the call
Nosh rings someone back, so it must know who. The dispatch sends their name as a required start value:nosh/agent.py
customer_name is a str of 1 to 40 characters, trimmed. A dispatch without
it is refused with Missing call_start fields: customer_name, and an empty or
too long one with call_start: customer_name: .... The call never reaches the
greeting. The prompt reads the name too: you rang {{customer_name}}.
GREETING.check(None, CALL_START) runs at import: a greeting that names a value
nothing fills before it fails then, not on a call.
The registry
Nosh has no tasks. It is oneAgentSpec with a ToolRegistry. The registry
holds the tools, what each result saves, and the line code says for it.
nosh/agent.py
Why it works this way
Every result speaks. Each tool returns a Pydantic model that carries the next line. The registry checks the result, saves its values and says the line. The model is asked for no reply. So a caller turn costs one model request: the one that picks the tool. What the caller agreed to is locked. A search runs only on the spot read back to the caller:find_places locks {{spot}}. The model cannot search
somewhere else by mistake. A new area replaces the spot, and its result clears
the old offer.
The read-back carries the next step. With no craving and no exact address,
find_area returns ask_craving: true, and its line asks for one. A “yes” to
the read-back never makes the model invent a craving. A craving said with the
area rides on the spot, so find_places needs no craving on the caller’s yes.
A pick must have been offered. place_details locks {{offer}} and takes
only an id like p1. An id that was never offered raises ToolError, with the
offer listed, so the model can ask the caller again.
Words come from data. Prices and hours are never worded by the model.
speech.py turns the place service’s fields into words. Nothing is said that
the service did not return.
The goodbye is code’s. end_call has an announce line and
announce_wait=True. The line plays in full, then the call ends.
What a call costs
The parts
Best practice.
- Give each result shape its own
ToolReply, keyed by a field likefound. - Save the result as a typed value, then lock it into the next tool. The model never copies an id.
- Keep the spoken text in the result, and say it with
{{result.spoken}}. Build it in code from the data.
Try it
WithoutTOOLS_API_KEY, Nosh answers from demo places in Barcelona. The
project’s .env.example sets UNMUTE_CALL_START to {"customer_name": "Sam"}.
- “Tapas in the Gothic Quarter, Barcelona.” Code reads the area back.
- “Yes.” Code lists three places.
- “The first one.” Code says its address, price and today’s hours.
- “Actually, what about the Born instead?” A new area takes back the old list.
- “Sushi.” Nothing is found, on purpose. Code offers to look wider.
Troubleshooting
The model lists the places in its own words
The tool’s docstring does not say who speaks the result. Fix: end the docstring with “The application says them.” The model reads it as the tool’s description.A pick of “the second one” is refused
The model sent the place’s name, not its id. Fix: describe the parameter as “The id of the place they picked from the offer, like p1.” The refusal lists the offer, so the model corrects itself on the same turn.Where to go next
Kestrel: a long intake
Tasks that ask every question in code, and a read-back before filing.
Save a result and speak it
Every rule of
Assign and ToolReply on a registry.