Custom Function Calls
Every tool your agent can call is, from the LLM's point of view, a function: a name, a description, and a typed list of arguments. The LLM picks which function to call and fills in the arguments based on the conversation.
In Bolti, the Input Schema field on a workspace HTTP tool is exactly that function signature. This page is about writing it well.
The mental model
When you assign a tool to an agent, here is what the LLM actually sees on every turn:
{
"name": "book_appointment",
"description": "Book a 30-minute consultation slot for the caller.",
"parameters": {
"type": "object",
"properties": {
"caller_name": { "type": "string", "description": "Full name of the caller." },
"preferred_date": { "type": "string", "description": "ISO date, e.g. 2026-05-12." },
"preferred_time": { "type": "string", "description": "24h HH:MM, e.g. 14:30." }
},
"required": ["caller_name", "preferred_date", "preferred_time"]
}
}
The model doesn't see your URL, your headers, or how you store the response. It only sees name, description, and parameters. Those three fields decide whether your tool gets called and how well it gets called.
In the dashboard, those map to:
| LLM field | Dashboard field |
|---|---|
name | Tool Name |
description | Description |
parameters | Input Schema |
Writing the schema
The Input Schema follows standard JSON Schema (the OpenAI function-calling subset). The dashboard provides a structured editor so you don't have to type braces, but understanding the shape helps.
Minimal example
{
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The customer's order ID, e.g. ORD-1234"
}
},
"required": ["order_id"]
}
That's enough. The model now knows: "to call this tool, I need a string called order_id. If the caller hasn't given me one, I should ask."
Supported types
| Type | Use it for | Example |
|---|---|---|
string | Names, IDs, free text, ISO dates | "caller_name" |
number / integer | Quantities, counts, prices | "quantity" |
boolean | Yes/no flags | "send_sms_confirmation" |
array | Lists of one type | list of items, list of dates |
object | Nested structures | address: { street, city, zip } |
Constraining the answer
The model is much more reliable when you constrain values. A few high-leverage constraints:
{
"type": "object",
"properties": {
"channel": {
"type": "string",
"enum": ["voice", "sms", "email"],
"description": "How to deliver the confirmation."
},
"rating": {
"type": "integer",
"minimum": 1,
"maximum": 5,
"description": "Caller's satisfaction rating."
},
"preferred_date": {
"type": "string",
"format": "date",
"description": "Date in YYYY-MM-DD format."
}
},
"required": ["channel", "rating"]
}
enum is especially powerful — it eliminates an entire class of mistakes the model can make.
Required vs optional
Anything in the required array must be filled in before Bolti will call your endpoint. The model will keep asking the caller until it has those values.
Anything not in required is optional. The model will skip it unless the conversation naturally surfaces it.
If you want the agent to ask the caller for something specific before doing the action, mark it required. If you want it to call the tool with whatever it has, leave it optional and provide a default in your endpoint.
Nested objects
Use them when the parameter is genuinely structured, not just to be tidy:
{
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zip": { "type": "string" }
},
"required": ["street", "city", "zip"]
}
},
"required": ["address"]
}
Flat schemas are easier for the model. If you find yourself nesting three levels deep, consider whether it should be two separate tools.
Lock the schema
The dashboard editor has a "Lock schema (no additional properties)" checkbox. Turning it on adds "additionalProperties": false to your schema, which forbids the model from inventing extra fields. Recommended for production tools — it catches drift early.
Writing the description
The description is not a comment for engineers. It's the prompt the LLM uses to decide whether to invoke your tool. Treat it like a one-paragraph spec for an intern.
A bad description
Calls the orders API.
The model has no idea when to use this. Will it pick it for "I want to return something"? For "What's my balance"? Maybe. Maybe not.
A good description
Look up the current status of an order by its ID. Use this whenever the caller asks about an order, shipment, delivery, tracking, or "where is my package". Do not use this for return requests — use
start_returnfor that.
Notice three things:
- What it does — one short sentence.
- When to use it — concrete trigger phrases the caller might say.
- When not to use it — disambiguation against neighboring tools.
The third one matters more than people think. If you have get_order and cancel_order, both descriptions should mention the other one to keep the model from guessing.
Per-parameter descriptions
Every property in your schema should have its own description. Use it to specify format, examples, and constraints in plain English:
"phone_number": {
"type": "string",
"description": "Caller's phone number in E.164 format. Always include the leading +. Example: +14155551212."
}
How arguments flow into your endpoint
When the model calls your tool, the arguments it produces are available as {{variable_name}} in your URL, headers, auth, and body. The variable name matches the property name in your input schema.
Input schema property: order_id
URL template: https://api.example.com/orders/{{order_id}}
Body template: { "id": "{{order_id}}", "channel": "voice" }
That's the contract: declare it in the schema, reference it in the request. There is no separate place to wire arguments to fields; the property name is the variable name. Templating details: Workspace HTTP Tools → Templating.
Patterns that work
One tool, one job
book_appointment and reschedule_appointment and cancel_appointment as three tools, not one tool with an action enum. The model picks better when each tool has a tight, obvious purpose.
Few tools per agent
3–6 tools per agent is the sweet spot. With 15+, the model starts hesitating, calling the wrong one, or not calling any. Trim aggressively.
Names that read like sentences
get_account_balance(account_id) reads naturally. gab(aid) does not. The model isn't doing autocomplete — it's reading.
Use enum for state machines
Anywhere there's a finite set of values (status, channel, priority, region), use an enum. The model will stop hallucinating values that "sound right".
What's next
- Wire up your first tool end-to-end: Workspace HTTP Tools
- Hang up cleanly: End Call
- Try a tool without making a real call: Testing Tools
- Pull live values from the call into your tool requests: Customizations → Context & Variables