Skip to main content

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 fieldDashboard field
nameTool Name
descriptionDescription
parametersInput 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

TypeUse it forExample
stringNames, IDs, free text, ISO dates"caller_name"
number / integerQuantities, counts, prices"quantity"
booleanYes/no flags"send_sms_confirmation"
arrayLists of one typelist of items, list of dates
objectNested structuresaddress: { 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.

Required is a behavior lever

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_return for that.

Notice three things:

  1. What it does — one short sentence.
  2. When to use it — concrete trigger phrases the caller might say.
  3. 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