Blog

Voice agent function calling with bound parameters

Voice agent function calling on Talkif: describe the endpoint once, then bind each parameter to the model, the call or a fixed value no caller can spoof.

A schematic on black. Three blocks across the top labelled model, call context and static. Below them a dashed group labelled request containing three slots labelled path, query and body; an arrow leads from the group to a block on the right labelled your server. model feeds path, call context feeds query, static feeds body. The query slot is outlined in coral, and a dashed line from model toward it is crossed out with a coral mark.
Bekir İşgörCo-founder
8 min read

Share

Updated 14 September 2026 with the current API shape, figures, and what the test call shows you.

To let a Talkif agent call your API during a call — voice agent function calling — you describe the HTTP request once: method, URL, and the parameters that go in the path, the query string and the body. Attach it to an agent, and the model calls it when the conversation needs it. The part that matters more than the request shape is the binding on each parameter. A parameter bound to the conversation is one the model fills from what the caller said. A parameter bound to the call — the caller's phone number, the matched contact's id — is filled by Talkif from what it already knows, and the model never sees it in its tool schema, so it cannot fill it, guess it, or be talked into a different value. A parameter bound to a fixed value is the same, with the value set by you. The rest of this post builds one function that way and follows it into a live call.

The example: an order lookup the caller cannot spoof

The endpoint is GET https://api.example.com/orders/{orderId}. It returns the order's status and delivery estimate, and it takes a phone query parameter so it can refuse to return an order that does not belong to that number. Three parameters, three different bindings:

  • orderId in the path — the caller reads it out, so the model extracts it.
  • phone in the query — the number the call is actually connected to, filled from the call. The caller cannot claim to be someone else by saying so.
  • include in the query — always eta, fixed.

This is the request to create it, POST /api/v1/flow-functions with your API key (or Flow Functions in the flow builder's toolbar, which posts the same thing):

{
  "name": "lookup_order",
  "description": "Look up the status and delivery estimate of an existing order. Call this as soon as the caller gives an order number; the number is digits only, usually six to eight of them.",
  "request": {
    "method": "GET",
    "url": "https://api.example.com/orders/{orderId}",
    "pathParams": {
      "type": "object",
      "properties": {
        "orderId": { "type": "string", "pattern": "^[0-9]{6,8}$", "description": "The order number the caller read out, digits only" }
      },
      "required": ["orderId"]
    },
    "queryParams": {
      "type": "object",
      "properties": {
        "phone": { "type": "string" },
        "include": { "type": "string" }
      },
      "required": ["phone"]
    },
    "body": { "type": "object", "properties": {}, "required": [] }
  },
  "paramBindings": {
    "phone": { "source": "call_context", "contextKey": "caller.phone_number", "onNull": "reject" },
    "include": { "source": "static", "value": "eta" }
  },
  "webhookHeaders": { "Authorization": "Bearer <your token>" },
  "timeoutMs": 3000
}

orderId has no binding, which means the model fills it. The pattern is checked against the model's argument before any request is sent; a mis-heard "order one two three" that arrives as 123 fails validation and the model is told, rather than your endpoint receiving a request for an order that cannot exist. The headers are encrypted at rest and never returned by the API again; the response only says hasWebhookHeaders: true.

Here is the function in one live call, from the caller reading the number to the agent reading the answer back:

A sequence schematic with four lanes: caller, model, backend, your server. Arrows in order: caller to model reads out the order number; model to backend lookup_order with orderId; a coral backend self-loop phone bound from the call; a backend self-loop orderId checked against the pattern; backend to your server get order; a dashed return status, eta; a dashed return tool result to the model; a dashed return says what it found to the caller.
One lookup during a call. The model supplies the order number; the backend adds the phone number from the call itself before the request leaves, and checks the order number against the pattern before your server sees it.

The model's part is the second line and the last one. Everything between them is the binding doing its work, and nothing the caller says can reach it.

What the model actually sees

Write the description for the model the way you would brief a new colleague. OpenAI's guidance for function definitions says it plainly: "Write clear and detailed function names, parameter descriptions, and instructions. Explicitly describe the purpose of the function and each parameter (and its format), and what the output represents" — and, as a check, "Pass the intern test. Can an intern/human correctly use the function given nothing but what you gave the model?" (OpenAI, Function calling guide). The description above says what the function is for, when to call it, and what the one argument looks like. That is the whole brief; the model reads nothing else about the function. Anthropic's tool-use documentation puts the same weight on it — "Provide extremely detailed descriptions. This is by far the most important factor in tool performance" — and gives a length: "Aim for at least 3–4 sentences for each tool description, more if the tool is complex" (Anthropic, Define tools). One line is not enough for a tool that fires in the middle of a phone call.

Because of the bindings, the tool the model receives is smaller than the function you defined. When the flow is published, include is removed from the schema — it is the same on every call. When a call starts, phone is removed too, because Talkif now knows the number. What reaches the model is a tool called lookup_order with exactly one parameter, orderId. Even if the caller says "my number is actually the other one", there is no phone argument for the model to change. And if the model did somehow send one, the value bound from the call overwrites it before the request is built.

A schematic of four cards in a row joined by arrows. The first, labelled defined, holds three slots: orderId, phone, include. An arrow labelled publish leads to a card labelled published where include is struck through. An arrow labelled call start leads to a card labelled this call where phone is struck through too. A final arrow leads to a card labelled what the model sees holding a single slot, orderId, outlined in coral.
The same function at four moments. Static parameters leave the schema at publish; context parameters leave at call start; the model is handed the one slot it is supposed to fill.

The context keys you can bind are the ones a call reliably has:

GroupKeysEmpty when
callercaller.phone_number, caller.contact_id, caller.contact_name, caller.contact_first_name, caller.contact_last_name, caller.contact_emailthe number matches no contact — an unknown inbound caller, say (the phone number itself is always present on a real call)
callcall.id, call.direction, call.started_atnever
flowflow.id, flow.namenever
accountaccount.idnever

The contact fields being empty is exactly what onNull is for.

When the bound value is missing

onNull has two settings, and they express different intentions. reject says the function makes no sense without this value: on a call where it is empty, the function is not offered to the model at all. For lookup_order, a call with no phone number is a browser test call, and a lookup that cannot verify ownership should not exist there — so it does not. fallback_to_llm says the value is useful but not essential: on a call where it is empty, the parameter is put back into the model's schema so the model can ask the caller for it. A function that emails a confirmation might bind email to caller.contact_email with fallback_to_llm — use the contact's address when we have one, ask when we do not.

Choose reject for anything that identifies or authorises. The model asking a caller "and what is your phone number?" and passing the answer to an endpoint that trusts it is the exact thing the binding exists to prevent.

Attach it, and decide where the conversation goes next

In the builder, add an HTTP Request node, pick lookup_order, and connect it to the tools handle of the agent that should be able to call it — it is only offered to the model while the conversation is on that agent. Attach the same function to more than one agent if more than one step needs it.

A function can also carry a transition: after it is called, the conversation moves to the agent you name. The move happens whether or not the request succeeded. That sounds wrong until you see what it is for: "once the order is looked up, go to the order agent" is a deterministic handoff, and if the lookup failed, the order agent receives the error as the tool result and can say so — "I couldn't find that order; could you read the number again?" — instead of the conversation stalling on the agent that asked.

What comes back, and what to return

Your endpoint's JSON body is handed to the model as the tool result; a non-JSON body arrives as { "raw": "<text>" }. A status of 200–399 is a success. Anything else, a timeout, or a network failure comes back as { "error": "<message>" } — the model sees the message and the prompt decides what it does with it. Nothing is retried; a person is on the line and a retry with backoff is silence. How a customer webhook gets called safely covers what happens between the model's call and your server, including the Talkif-Signature header you should verify before acting on the request.

Shape the response for reading aloud. The model will summarise whatever you return, and it summarises {"status": "shipped", "eta": "2026-09-16"} better than a forty-field order record. For failures, return a body the model can use: {"error": "not_found"} lets the prompt say "no order with that number"; a bare 500 lets it say only that something went wrong. And keep the endpoint fast — every millisecond it takes is silence on the line; the timeout above is three seconds, the ceiling is thirty.

Test it before you publish

The builder's test call runs the unpublished draft through the same compile as publish, with an empty caller — no phone number, no contact. That has one consequence for this function: with phone bound reject, lookup_order is removed from the test call's tools, and the agent will tell you it has no way to look up orders. That is correct behaviour, not a bug, and it is the fastest way to see a reject binding working. To exercise the function itself, place a real call to a number in your contacts, or bind phone with fallback_to_llm in a copy of the function while you test the endpoint, and switch it back before you publish.

After the call, the call's timeline shows each function by name with how long it took. Whether it succeeded is in the transcript — the agent said what it found or that it could not — and in the response your endpoint logged. Production use of this path is still light: across the 30 days to 12 September 2026 there were two server-side function executions, so if you build on it, you will be among the first to find the edges. The reference for every field is at docs.talkif.ai/build/call-your-backend.

Read next
  1. Developers

    Turn detection that doesn't talk over you

    How our voice agent's turn detection decides a caller has finished, when an interruption is real and when to stay quiet, and the incidents behind it.

Questions about this piece? Write to us.