Developer Companion

CallFunc β€” Developer Reference

Setting up tools in Perxona β€” a hands-on guide for builders

Readable HTML layout Audience: Technical readers wiring the live demo backend
Blue blocks Concrete actions to take in the console.
Amber blocks Common gotchas, limits, and caveats.
Green blocks Checks that tell you things are working.
Purple blocks Prompt snippets you can hand to AI tools.

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:

  1. Should I call a tool at all? β†’ driven by the name + description.
  2. Which one? β†’ driven by how distinct the descriptions are from each other.
  3. With what arguments? β†’ driven by the input schema (field names, types, required, descriptions, enums, defaults).
  4. What did I get back / what do I tell the user? β†’ helped by the output schema (so it knows event_link exists, that count is 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)

flowchart TB A["LLM reads name + description + input schema
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 (because require_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 emit 2026-06-11 and 16:00 instead 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_minutes has default: 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_link as a clickable link, or read message back verbatim);
  • understand types (it knows count is a number, topics is 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_confirmation human-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. mock vs real event_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_VALIDATION CallFunc 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

  1. Two objects: one OutboundAPI (URL + API key) + one CallFunc per function (name, description, method, path, input schema, output schema, confirm).
  2. The model only sees name + description + input schema + output schema β€” write them for a smart stranger.
  3. Description = what it does + when to use it (+ what it returns, + what NOT to do).
  4. Input schema = field names, types, required, formats, defaults, enums.
  5. Output schema = what comes back, so the model reads results correctly.
  6. Writes get require_confirmation: true. Reads don’t.
  7. 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.