This manual shows you how to turn the three demo HTTP endpoints (get_schedule,
get_speaker, book_meeting) into tools your Perxona agent can call β and, just as
importantly, how to write them so the LLM actually understands when and how to use them.
Companion docs: API (authoritative schemas).
1. How an LLM “sees” a tool
When the agent runs, Perxona hands the model a list of tools. Each tool is essentially this (the same shape OpenAI and Anthropic use under the hood):
{
"name": "get_schedule",
"description": "List conference sessions. Optionally filter by day, track, or speaker.",
"parameters": { /* the INPUT JSON Schema */ }
}
On every turn the model asks itself, using only that text:
- Should I call a tool at all? β driven by the name + description.
- Which one? β driven by how distinct the descriptions are from each other.
- With what arguments? β driven by the input schema (field names, types,
required, descriptions, enums, defaults). - What did I get back / what do I tell the user? β helped by the output schema (so it
knows
event_linkexists, thatcountis a number, etc.).
So your job as a builder is to write those four things clearly. That’s the whole craft.
What actually happens on a call (under the hood)
decides to call, emits arguments matching the input schema"] B["Perxona validates args (JSON Schema β Pydantic),
renders $context / $story templates into the URL/headers,
injects your secret, and makes the HTTP request"] C["Your server runs and returns JSON"] D["Perxona feeds the JSON back to the LLM
(the output schema helps it interpret),
which then writes the user-facing reply β or calls another tool"] A --> B --> C --> D
You only build the server + the four text/shape signals. Perxona does the validation, secret injection, the HTTP call, usage limits, and (optionally) the user confirmation.
2. The two building blocks in Perxona
Setting up a tool is always two objects:
| Object | What it is | What you put in it | Does the LLM see it? |
|---|---|---|---|
| OutboundAPI | The connection: where your server lives + how to authenticate | Base URL, secret (API key) | No β infrastructure only |
| CallFunc | The tool the agent can call | Name, description, method, path, input schema, output schema, require_confirmation |
Yes β this is the contract |
- Create one OutboundAPI for the demo server (one base URL + one API key).
- Create three CallFuncs (one per function), all pointing at that OutboundAPI.
CallFuncs live inside a Storyboard’s Panel, so a tool can be available only in the conversation states (panels) where it makes sense.
3. Setup walkthrough
Step 0 β Get your public URL + API key
cd /Users/kilikkuo/Projects/perxona-callfunc-demo
./scripts/run.sh # terminal 1: server on :8090
./scripts/tunnel.sh # terminal 2: prints https://<random>.trycloudflare.com
grep API_KEY .env # your X-API-Key value
Sanity check the public URL (no key needed for health):
curl -s https://<random>.trycloudflare.com/health
Step 1 β Create the OutboundAPI
In the admin console (Backend Core / Outbound API), create one entry:
| Field | Value |
|---|---|
| Base URL | https://<random>.trycloudflare.com |
| Secret type | API_KEY |
| Header name | X-API-Key |
| Header value | (the API_KEY from .env) |
Step 2 β Create CallFunc #1: get_schedule
| Field | Value |
|---|---|
| Type | OUTBOUND_API |
| Name | get_schedule |
| Method / Path | GET Β· /get_schedule |
| Description | List conference sessions. Optionally filter by day ("Day 1"/"Day 2" or a date like 2026-06-11), track (e.g. AI/ML), or speaker name. Use this when the user asks what talks/sessions are happening or when something is scheduled. |
require_confirmation |
false (read-only) |
Input schema (query params):
{
"type": "object",
"properties": {
"day": { "type": "string", "description": "Day label ('Day 1') or ISO date ('2026-06-11')." },
"track": { "type": "string", "description": "Track substring, e.g. 'AI/ML'." },
"speaker": { "type": "string", "description": "Speaker name substring." }
}
}
Output schema (the “Output data” field) β see API Β§1 for the full block:
{
"type": "object",
"properties": {
"count": { "type": "integer", "description": "Number of sessions returned." },
"sessions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type":"string"}, "title": {"type":"string"}, "speaker": {"type":"string"},
"track": {"type":"string"}, "room": {"type":"string"}, "day": {"type":"string"},
"date": {"type":"string"}, "start": {"type":"string"}, "end": {"type":"string"}
}
}
}
}
}
Step 3 β Create CallFunc #2: get_speaker
| Field | Value |
|---|---|
| Type | OUTBOUND_API |
| Name | get_speaker |
| Method / Path | GET Β· /get_speaker |
| Description | Look up a conference speaker by name (or exact id). Returns their title, company, bio, topics, and the sessions they present. Use this when the user asks about a speaker, who someone is, or what they're talking about. |
require_confirmation |
false |
Input schema:
{
"type": "object",
"properties": {
"name": { "type": "string", "description": "Speaker name to search for (substring)." },
"id": { "type": "string", "description": "Exact speaker id, e.g. 'spk_chen'." }
}
}
Output schema β see API Β§2: count + speakers[] with
id, name, title, company, bio, topics[], sessions[].
Step 4 β Create CallFunc #3: book_meeting
| Field | Value |
|---|---|
| Type | OUTBOUND_API |
| Name | book_meeting |
| Method / Path | POST Β· /book_meeting |
| Description | Book a 1:1 meeting with a speaker at a specific date and time. Creates a calendar event and returns a confirmation. Use this only when the user explicitly wants to book/schedule a meeting with a named speaker. |
require_confirmation |
true (it’s a write β make the user approve) |
Input schema (payload):
{
"type": "object",
"required": ["attendee_name", "speaker", "date", "start_time"],
"properties": {
"attendee_name": { "type": "string", "description": "Name of the person requesting the meeting." },
"speaker": { "type": "string", "description": "Name of the speaker to meet." },
"date": { "type": "string", "description": "Meeting date, YYYY-MM-DD." },
"start_time": { "type": "string", "description": "Local start time, 24h HH:MM." },
"duration_minutes": { "type": "integer", "minimum": 5, "maximum": 240, "default": 30 },
"attendee_email": { "type": "string", "description": "Optional attendee email." },
"topic": { "type": "string", "description": "Optional meeting topic." }
}
}
Output schema β see API Β§3: booking_id, status, mode, summary, speaker, start,
end, timezone, event_link, message.
Step 5 β Test it
In a panel conversation, try:
- “What AI/ML talks are on Day 1?” β should call
get_schedule(day:"Day 1", track:"AI/ML"). - “Who is Evelyn Chen and what’s she presenting?” β
get_speaker(name:"chen"). - “Book me 30 min with Evelyn Chen on June 11 at 4pm about architecture.”
β
book_meeting(...)β confirmation prompt (becauserequire_confirmation:true) β event created.
4. The craft: writing descriptions & schemas the LLM understands
This is the part that decides whether the agent feels smart or dumb. Some rules of thumb, with before/after.
4.1 Name = the verb the model matches on
Use a clear verb_noun: get_schedule, book_meeting. Keep the three names distinct so the
model never has to guess between two similar tools.
4.2 Description = when to use it, not just what it is
The description is read on every turn as the model decides whether to call. Say what it does and the trigger (“use this when⦔), plus any limits.
| β Vague | β Clear |
|---|---|
| “Schedule endpoint.” | “List conference sessions. Optionally filter by day, track, or speaker. Use this when the user asks what talks are happening or when something is scheduled.” |
| “Books a meeting.” | “Book a 1:1 with a speaker at a date/time. Use only when the user explicitly wants to book. Creates a calendar event and returns a confirmation.” |
Tips: - Mention what it returns (“returns title, company, bio, topics”) so the model knows the tool can answer the question. - State negative space (“only when the user explicitly wants to book”) to stop premature or accidental calls β especially for writes. - Don’t dump implementation details (DB, framework). The model doesn’t care and it wastes the context the model reasons over.
4.3 Input schema = the argument contract
Every property description is a hint the model uses to fill that field. Be concrete about format, because the model will copy your wording.
- Formats:
"Meeting date, YYYY-MM-DD."and"Local start time, 24h HH:MM."make the model emit2026-06-11and16:00instead of"next Tuesday"or"4 PM". required: list the fields the call can’t work without (attendee_name, speaker, date, start_time). The model will keep asking the user until it has them.- Defaults & bounds:
duration_minuteshasdefault: 30,minimum: 5,maximum: 240β so the model can omit it, and bad values are rejected before they hit your server. - Enums (not in this demo, but powerful):
{"type":"string","enum":["confirmed","tentative"]}constrains the model to valid choices. - Keep it flat and small. Deeply nested input is harder for the model to fill reliably.
4.4 Output schema = how the model reads the result (and why you were asked to add it)
After the call, the model reads your JSON response. The output schema tells it what fields exist and what they mean, so it can:
- pull the right value into its reply (e.g. surface
event_linkas a clickable link, or readmessageback verbatim); - understand types (it knows
countis a number,topicsis a list); - avoid hallucinating fields that aren’t there.
Describe each output field the same way you describe inputs. For book_meeting, noting that
event_link is string | null (null in mock mode) and that mode says "google_calendar"
vs "mock" lets the agent phrase the confirmation honestly.
4.5 Golden rule
5. Template variables (optional, good to know)
Perxona can inject conversation context into the request without asking the LLM for it,
using {$context.x} and {$story.x} placeholders in the URL/headers/fixed params. Example:
put the logged-in user’s id in a header as {$context.user_id} so book_meeting doesn’t need
the model to pass it. The demo doesn’t require this, but it’s how you keep secrets and known
context out of the model’s hands.
6. Safety & UX knobs
| Knob | What it does | Use it for |
|---|---|---|
require_confirmation: true |
Shows the user an approve/deny prompt before the call runs | Any write (book_meeting), anything costly or irreversible |
Usage limits (call_limit / step_limit) |
Caps how many times a tool can run per turn/conversation | Stop runaway loops and API abuse |
| Secret type (API_KEY / Bearer / None) | Auth header injected at call time, stored encrypted | Keep your endpoint private; never put keys in the description |
| Panel scoping | A CallFunc only exists in the panels you add it to | Hide booking tools until the user is in a “booking” state |
For this demo: book_meeting β require_confirmation: true; the two get_* reads β false.
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 from the tool |
Missing/wrong X-API-Key |
Re-check the OutboundAPI secret value + header name |
422 |
Bad date/start_time format or missing required field |
Tighten the field description (formats) and the required list |
502 (book_meeting) |
Google configured but insert failed | Check the calendar is shared with the service account |
| The model never calls the tool | Description too vague / overlaps another tool | Rewrite the description with a clear “use this when⦔ trigger; make names distinct |
| The model calls it with wrong args | Field descriptions unclear, missing formats/enums | Add description, formats, enum, required, defaults |
| The model double-books or loops | No confirmation / no limits | Set require_confirmation: true and usage limits |
| Public URL 404/timeout | Tunnel restarted (new URL) or server down | Re-run ./scripts/tunnel.sh, update the OutboundAPI base URL, re-check /health |
8. Perxona CallFunc vs. MCP and other agentic tool designs
All modern tool systems share the same core idea you just used: a tool is a name + description + JSON Schema, and the model reasons over that contract. They differ in who hosts the tool, how it’s transported, and what the platform does for you.
Quick comparison
| Dimension | Perxona CallFunc / OutboundAPI | MCP (Model Context Protocol) | Native function calling (OpenAI / Anthropic tool use) |
|---|---|---|---|
| How you define a tool | In the admin UI: URL + JSON Schema + description (no code) | Write & run an MCP server exposing tools | Declare tools inline in your API request; your app executes them |
| Code required | None (just the HTTP endpoint, which can be any existing API) | Yes β implement the MCP server | Yes β implement the tool runner in your app |
| Transport | HTTPS request/response only | stdio or HTTP/SSE; stateful sessions | In-process to your app; you make provider calls |
| Tool discovery | Static (configured ahead of time per panel) | Dynamic (tools/list); servers advertise tools at runtime |
Static (you pass the tool list each request) |
| Beyond tools | Tools only | Resources, prompts, sampling, notifications | Tools only (provider-specific extras) |
| Streaming / long-running | No (single round trip) | Supported via the protocol | Depends on your app |
| Auth & secrets | Built in β encrypted secret injected at call time | You implement it in the server | You implement it in your app |
| Human-in-the-loop | Built in (require_confirmation) |
Client-dependent (e.g. Claude Desktop prompts) | You build it |
| Usage limits / safety | Built in (call/step limits, SQL-AST safety) | You build it | You build it |
| Multi-tenant / managed | Yes β scoped per org, hosted by the platform | You operate the server | You operate the app |
| Portability / reuse | Perxona-only (proprietary) | Open standard β reuse one server across Claude Desktop, IDEs, other MCP clients | Provider/SDK-specific, but a widely-shared shape |
| Best whenβ¦ | You build inside Perxona and want no-code HTTP tools + managed guardrails | You want reusable, discoverable tool servers across many AI clients, with resources/state | You’re building your own agent app and want full control |
Pros of the Perxona CallFunc approach
- No-code / low-code. Any HTTP API becomes a tool by pasting a URL + JSON Schema. Perfect for vibe coders and for turning an existing backend into agent tools in minutes β no adapter server to write or host (an MCP integration means building and operating a server).
- Batteries included. Encrypted secrets, per-tool usage limits,
require_confirmationhuman-in-the-loop, context templating ({$context}/{$story}), text-to-SQL safety, logging and multi-tenant scoping are all provided. With MCP or native function calling you build all of that yourself. - Tight product integration. Tools live inside the persona/storyboard flow and can be panel-scoped (available only in the right conversation state), and they ride the same pipeline as memory, guardrails, and the conversation transcript.
- Output schema as a first-class field. You declare what comes back, which helps the model
interpret results and keeps responses honest (e.g.
mockvs realevent_link).
Cons / limits
- Proprietary & platform-locked. Your tools live in Perxona; they’re not portable to other hosts. MCP’s biggest win is reuse β write a tool server once and use it from Claude Desktop, IDEs, and any MCP-compatible client. A CallFunc can’t be reused outside Perxona.
- HTTP request/response only. No local/stdio tools, no streaming or long-running jobs, no
stateful tool sessions, and none of MCP’s resources / prompts / sampling primitives. (In
the current backend, the
DATA_VALIDATIONCallFunc type isn’t implemented, and there’s no tool-result caching, batching, or circuit-breaker β it’s one clean round trip.) - Static configuration. Tools are set up ahead of time in the UI; there’s no runtime
discovery like MCP’s
tools/list. Changing a tool means editing it in the console. - You must host a public endpoint. It has to be reachable from Perxona (hence the cloudflared tunnel), and you own its uptime, latency, timeouts, and TLS. MCP servers can run locally next to the client; native functions run inside your own app.
- Schema-dialect coupling. Input/output use JSON Schema (Draft 2020-12) converted to
Pydantic via
jambo; very exotic schema features may not convert cleanly. Keep schemas simple and flat (which is good practice for the model anyway).
How to think about it
- These approaches aren’t mutually exclusive in spirit β they all rest on name + description + schema. What you learned here transfers directly to MCP and to native function calling.
- Use Perxona CallFunc when you’re shipping inside Perxona and want the fastest path from “an HTTP API exists” to “the agent can use it,” with confirmation/limits/secrets handled for you β exactly this demo.
- Reach for MCP when you want a tool/data server that’s reusable across many AI clients, needs local or stateful access, or wants resources/prompts beyond plain function calls.
- Use native function calling when you’re building your own agent application directly on an LLM API and want end-to-end control of execution.
9. One-page recap
- Two objects: one OutboundAPI (URL + API key) + one CallFunc per function (name, description, method, path, input schema, output schema, confirm).
- The model only sees name + description + input schema + output schema β write them for a smart stranger.
- Description = what it does + when to use it (+ what it returns, + what NOT to do).
- Input schema = field names, types,
required, formats, defaults, enums. - Output schema = what comes back, so the model reads results correctly.
- Writes get
require_confirmation: true. Reads don’t. - It’s HTTP + managed guardrails, no code β great for speed inside Perxona; MCP wins on reuse/portability and richer primitives; native function calling wins on full control in your own app.