Functions
A Function is a small piece of TypeScript your agent can call as a tool. Use one when a single API call isn't enough: call several APIs, merge the results, calculate prices or indicators, clean up messy JSON. Models are bad at arithmetic and long JSON; code isn't. Functions run in a locked sandbox that can reach only the sites you allow and never holds your keys.
Function or HTTP tool?
| Use an HTTP tool when… | Use a Function when… |
|---|---|
| One API call answers the question. | You need two or more calls, or one call depends on another. |
| The API takes the inputs as query parameters or a flat JSON body. | The API needs a nested body, special encoding or a signature. |
| The response just needs trimming to a few fields. | You need to calculate, filter, sort, convert units or combine data. |
| You want no code at all. | You're fine with code, or the Copilot writes it for you. |
Both kinds live together on the Tools page and share one set of names: a Function can't have the same Name the AI calls as an HTTP tool.
Add a Function
- Open the agent and go to Tools.
- Below the list, click Function. A Function called New function opens with starter code.
- On the Details tab, fill in the title, description, inputs and code, and list the Allowed sites it calls.
- If it needs an API key, add it under Keys.
- Open the Test tab, write an input and click Run.
- Publish the agent. Functions are part of the draft: customers get changes after you publish. The Test panel uses the draft right away.
Each Function card has an Enabled switch. When it's off, the agent isn't offered the Function. It stays in the draft and automations can still run it.
The fastest way to get a working Function is to ask the Copilot: "Add a Function that takes an order number, gets the order from ShopX and the tracking from the carrier, and returns both." It writes the code, runs it with realistic input, reads the result and fixes it. You approve the key on a card; the Copilot never sees its value.
Function settings (Details tab)
| Setting | What it does | Limit / default |
|---|---|---|
| Title | The name people see in Peki. | Required, up to 80 characters |
| Name the AI calls | The tool name for the model, like shipping_quote. | Lowercase letters, digits and _, starting with a letter, up to 64 characters. Unique among tools and Functions. |
| Description the AI sees | What it does and when to use it. | Required, up to 1,000 characters |
| Inputs | What the model fills in. Same editor as HTTP tool inputs: name, Text / Number / Yes / no, Required, description, Needs the real personal data. | Up to 20 |
| Code | TypeScript, in index.ts. | Required, up to 20,000 characters, must have a default export |
| Needs approval / When rejected | The call waits for your team's approval first. See Approvals. | Off / Tell the customer |
| Allowed sites | The only hosts the code can reach. | Up to 10 |
| Keys | This agent's keys the code may use, and where each one goes. | Up to 10 |
| Timeout | The longest one run may take: 1, 2, 3, 5, 8 or 10 seconds. | 5 seconds, at most 10 |
Writing the code
The shape of a Function
A Function is a module whose default export is an async function. It gets the input the model filled in and a ctx object, and returns the result:
export default async (input, ctx) => {
// input: the values the model filled in, checked against your Inputs
// ctx: fetch, log, now (and records / store / trigger in some runs)
return { ok: true };
};
You can write plain JavaScript or add TypeScript types. The editor highlights TypeScript but doesn't check types; the code runs either way.
- Input. Before the code runs, the input is checked against the Inputs you defined. A wrong type or a missing required input stops the run with
Invalid input: <name>: <reason>, and the code never starts. - Result. Return any JSON value: an object, an array, a string, a number. Returning nothing gives
null. The model reads the result as JSON. Return small, clean objects with clear field names; that's what the model reads best. - Errors. Throw an
Errorto fail the run. The model reads{"error": "<your message>"}and can tell the customer or try other input, so write messages that help:throw new Error('No order 48213 for this email').
What ctx gives you
| Member | What it does | Available |
|---|---|---|
ctx.fetch(url, init?) | Makes an HTTP request to one of the Allowed sites. | Always |
ctx.log(...values) | Writes a log line. console.log, info, warn, error and debug do the same. | Always |
ctx.now() | The current time in milliseconds, like Date.now(). | Always |
ctx.records.find(table, { where, sort, limit }) / ctx.records.save(table, data) | Read and save the agent's records. | Agents with record tables. See Records. |
ctx.store.get/set/delete(key) | A small memory that lasts between runs. | Automation runs only. See Automations. |
ctx.trigger | What started the run. | Automation runs only. |
ctx.fetch
const res = await ctx.fetch('https://api.yourshop.com/v1/orders/48213', {
method: 'GET', // GET (default), POST, PUT, PATCH, DELETE, HEAD
headers: { 'Accept-Language': 'en' },
body: { note: 'hello' }, // a string, or an object that is sent as JSON
secret: { name: 'ShopX API' }, // optional: one of the Function's Keys, by name
});
res.status; // 200
res.ok; // true for 2xx
res.headers.get('content-type');
await res.text(); // the body as text
await res.json(); // the body parsed as JSON
- When
bodyis an object, it is sent as JSON andcontent-type: application/jsonis set for you. - A non-2xx answer doesn't throw. Check
res.okorres.statusyourself. - A refused request throws an
Errorlikectx.fetch https://… failed: <reason>. See Errors. - Redirects are not followed: a
301or302comes back as that status. Call the final URL. - The global
fetchcan't reach anything. Always usectx.fetch.
Helpers you can import
Imports are blocked, except for two bundled helpers:
import { sma, ema, rsi } from 'ta'; // moving averages and RSI over number arrays
import { encodeBase64 } from '@std/encoding'; // base64, hex and other encodings
sma(values, period)andema(values, period)return moving averages.rsi(closes, period = 14)returns RSI values;rsi(closes).at(-1)is the latest.@std/encodinggives the standard encoders and decoders (base64, base64url, hex and more).
Any other import fails, including npm packages and URLs. Standard JavaScript built-ins such as JSON, Math, Date, URL, Intl, TextEncoder and structuredClone are available.
What the code can't do
- Reach any site that isn't in Allowed sites, or any private network.
- See a key's value.
- Read or write files, read environment settings, start other programs, or load code from the internet.
- Keep anything between runs (except
ctx.storein automations). Each run starts fresh. - Run longer than its Timeout.
Network rules (egress)
Every ctx.fetch goes through Peki's network proxy, which enforces these rules. They are security limits and can't be turned off.
- Allowed sites only. The host must match one of the Function's Allowed sites, or the call fails with api.other.com is not in this function's hosts (…): add it to the function's hosts. Enter host names only, like
api.yourshop.com; Peki removeshttps://and paths you paste.*.yourshop.comallows every subdomain (api.yourshop.com,cdn.yourshop.com) but notyourshop.comitself; add both if you need both. - Only
httpandhttps. - No private or local addresses. The same ranges as HTTP tools are blocked, checked after DNS, so a host name can't point at an internal service.
- At most 20 requests per run. The 21st fails with A function run may make at most 20 requests.
- 1 MB per response and per request body.
- Time. A request may only use the time left in the run.
Keys in Functions
The code never holds a key. It only names one; the proxy adds the value on the way out.
- Under Keys, click Add key, then choose one of the agent's keys or + Add a new key.
- Choose Where the key goes:
- Header, empty name: sent as
Authorization: Bearer <key>(Authorization: Bearer). - Header with a name, like
X-API-Key: sent as that header with the raw key. - Query with a parameter name, like
api_key: added to the URL as?api_key=<key>.
- Header, empty name: sent as
- In the code, ask for it by its name:
ctx.fetch(url, { secret: { name: 'ShopX API' } }).
The proxy attaches a key only when all of these hold:
- the key is in the Function's Keys;
- the URL's host is in the Function's Allowed sites and in the key's own allowed sites;
- the URL uses
https.
More rules that protect your keys:
- Keys must be at least 8 characters. Shorter keys can't be found reliably in responses, so they are refused: This key is too short to protect; use the HTTP tool instead.
- A response that contains one of the run's keys is withheld with an error, even if that request didn't carry a key. APIs that echo keys back in errors would otherwise leak them to the code.
- When a Function uses keys, its responses must be uncompressed UTF-8 text. Peki asks servers not to compress. A response it can't check (compressed or in another charset) is withheld.
- Some headers can't carry a key or be set by code:
host,connection,content-length,transfer-encodingand other connection headers. - Webhook signing keys are never sent. They only check incoming signatures.
Because the code never receives a key's value, a Function can't compute a signature (like an HMAC) with a key. Use an API that accepts the key in a header or query parameter.
See Variables and secrets for adding keys and their allowed sites.
Limits
| Limit | Value |
|---|---|
| Timeout per run | 1–10 seconds, default 5 |
| Memory per run | About 256 MB |
| Result | 16 KB (as JSON) |
| Logs per run | 8 KB; later lines are dropped |
| Outbound requests per run | 20 |
| Response size per request | 1 MB |
| Code size | 20,000 characters |
| Runs per agent per minute | 60 by default |
| Runs at the same time per agent | 4 by default |
| Run time per agent per day | 3,600 seconds by default, reset at midnight UTC |
| Functions per agent | 30 |
How the daily budget works: a run reserves its full Timeout before it starts and gives back what it didn't use when it ends. If the reservation doesn't fit in what's left today, the run is refused. Keep timeouts close to what the Function really needs.
Inside a customer reply, a Function also has to fit in the reply's own time: Peki keeps about 8 seconds for the model to write the answer after the last Function call. If there isn't enough time left, the call fails with Not enough time left in this reply to run the function.
The model reads at most 16 KB of a result and 4 KB of an error.
Personal data
Functions handle personal data like HTTP tools. With personal-data protection on, the model fills inputs with placeholders like [PHONE_1]. Inputs marked Needs the real personal data get the real value; the others keep the placeholder. Personal data in the result is turned into placeholders before the model reads it, and run records keep placeholders too. See Guardrails and privacy.
Test a Function
Open the card's Test tab.
- Write the Input as a JSON object. The editor starts with every input and an empty value, for example
{"weight_kg": 0, "city": ""}. - Click Run or press ⌘ Enter (Ctrl Enter on Windows).
- Read the output:
- Success with the Result, or Failed with the Error.
- Logs: everything the code logged.
- Requests: every
ctx.fetchwith its Host, Status and Time. A refused request shows status0. - The run's duration.
Things to know about test runs:
- Run saves the draft first, then runs the saved draft. If the draft has problems, fix them first: Fix the problems above so the draft can be saved, then run again.
- Test runs are real. They use your real keys and may send
POST,PUTandDELETErequests, which can change data in your systems. Use test data where you can. ctx.records.savein a test run creates or updates test records only, never live ones.- Only editors, admins and owners can run tests: Only editors can run a test.
- Keys are added by the proxy. Code never sees their values.
Runs and logs
The Runs tab lists the Last 50 runs of the Function, newest first, from every source: agent (customer conversations), test, Copilot and automation, with who started it. For each run you see the time, the source, ok or error, and the Duration. Open a run to see its input, result or error, logs and requests. Open in Test copies its input to the Test tab so you can run it again.
Use Show → Errors to list only failed runs.
Run records are kept for 30 days. Inputs, results and logs are stored capped, with keys masked and personal data as placeholders.
Errors
| Error | Cause | What to do |
|---|---|---|
Invalid input: <name>: … | The input doesn't match the Function's Inputs. | Fix the input types, or describe the format in the input's description. |
<Name>: <message> (your own error) | The code threw. | Read Logs and the message. |
Timed out after 5000 ms | The run took longer than Timeout. | Raise the timeout (max 10 s) or make fewer or faster calls. |
Result is 24 KB; the limit is 16 KB | The result is too large. | Return only the fields the model needs. |
Memory limit exceeded… | The run used too much memory. | Process less data at once. |
CPU limit exceeded… | The code calculated for too long. | Simplify the calculation. |
ctx.fetch … failed: api.other.com is not in this function's hosts… | The host isn't allowed. | Add it to Allowed sites. |
ctx.fetch … failed: A function run may make at most 20 requests | Too many calls. | Fetch in bulk, or split the work. |
ctx.fetch … failed: The secret ShopX API may only be sent to … | The URL isn't one of the key's allowed sites. | Use the right host, or ask an admin to change the key's sites. |
ctx.fetch … failed: The secret … is only sent over https | The URL uses http. | Use https. |
ctx.fetch … failed: The secret … is not in this function's secrets | The code names a key that isn't under Keys. | Add it under Keys, or fix the name. |
ctx.fetch … failed: …response withheld: it contained a key | The API echoed a key back. | Use another endpoint, or report it to the API's owner. |
This key is too short to protect; use the HTTP tool instead | A key under Keys has fewer than 8 characters. | Use a longer key, or an HTTP tool. |
This agent ran its functions 60 times in the last minute… | The per-minute limit. | Wait a minute. |
Too many Functions running for this agent right now | Too many runs at once. | Try again shortly. |
This agent used its 3600 seconds of function run time for today… | The daily budget is used up. | Wait until midnight UTC, or lower timeouts. |
ctx.store is only available in automation runs | ctx.store in a conversation or test. | Use it only in automations. |
ctx.records is only available to agents with tables | The agent has no record tables. | Add a table, or remove the call. |
The code must export default an async function… | No default export. | Start the code with export default async (input, ctx) => …. |
Functions are unavailable right now; try again shortly | A temporary problem on our side. | Try again. |
Examples
Shipping quote (no network)
Inputs: weight_kg (Number, required), zone (Text, required: "domestic, eu or world"). No allowed sites needed.
const RATES = { domestic: 4.5, eu: 9, world: 18 }; // USD for the first kg
const PER_EXTRA_KG = { domestic: 1, eu: 2.5, world: 5 };
export default async ({ weight_kg, zone }: { weight_kg: number; zone: string }) => {
if (!(zone in RATES)) throw new Error('zone must be domestic, eu or world');
if (weight_kg <= 0 || weight_kg > 30) throw new Error('We ship parcels from 0.1 to 30 kg');
const extra = Math.max(0, Math.ceil(weight_kg) - 1);
const price = RATES[zone] + extra * PER_EXTRA_KG[zone];
return { zone, weight_kg, price_usd: Math.round(price * 100) / 100, free_over_usd: 75 };
};
Order with tracking (two APIs, keys)
Inputs: order_id (Text, required). Allowed sites: api.yourshop.com, api.carrier.example. Keys: "ShopX API" (Header, empty name → Bearer), "Carrier API" (Query, api_key).
export default async ({ order_id }: { order_id: string }, ctx) => {
const orderRes = await ctx.fetch(`https://api.yourshop.com/v1/orders/${encodeURIComponent(order_id)}`, {
secret: { name: 'ShopX API' },
});
if (orderRes.status === 404) throw new Error(`No order ${order_id}`);
if (!orderRes.ok) throw new Error(`Shop API answered ${orderRes.status}`);
const order = await orderRes.json();
ctx.log('order status', order.status);
if (!order.tracking_number) return { order_id, status: order.status, tracking: null };
const trackRes = await ctx.fetch(`https://api.carrier.example/track/${order.tracking_number}`, {
secret: { name: 'Carrier API' },
});
const track = trackRes.ok ? await trackRes.json() : null;
return {
order_id,
status: order.status,
items: order.items.map((i: { name: string; qty: number }) => `${i.qty} × ${i.name}`),
carrier_status: track?.status ?? 'unknown',
last_scan: track?.events?.[0]?.location ?? null,
estimated_delivery: track?.eta ?? null,
};
};
Crypto signal with RSI (helpers)
Inputs: symbol (Text, required: "a trading pair like BTCUSDT"). Allowed sites: api.binance.com.
import { rsi, sma } from 'ta';
export default async ({ symbol }: { symbol: string }, ctx) => {
const url = `https://api.binance.com/api/v3/klines?symbol=${encodeURIComponent(symbol.toUpperCase())}&interval=1h&limit=100`;
const res = await ctx.fetch(url);
if (!res.ok) throw new Error(`Unknown symbol ${symbol}`);
const closes = (await res.json()).map((k: string[]) => Number(k[4]));
const latest = rsi(closes).at(-1)!;
return {
symbol,
price: closes.at(-1),
rsi14_1h: Math.round(latest * 10) / 10,
sma20: Math.round(sma(closes, 20).at(-1)! * 100) / 100,
signal: latest < 30 ? 'oversold' : latest > 70 ? 'overbought' : 'neutral',
};
};
Find a customer's open records (records)
For an agent with a leads table. Inputs: phone (Text, required, Needs the real personal data). No allowed sites needed.
export default async ({ phone }: { phone: string }, ctx) => {
const { records, more } = await ctx.records.find('leads', {
where: [{ field: 'phone', op: 'eq', value: phone }],
limit: 5,
});
ctx.log('found', records.length, more ? '(more)' : '');
return records.map((r: { id: string; title: string; data: Record<string, unknown> }) => ({
id: r.id,
title: r.title,
status: r.data.status ?? 'new',
}));
};
where is a list of up to 10 conditions (field, op: eq, in, range or contains, with value, values or min/max); sort takes a field and asc or desc. Use your table's own field keys. ctx.records.find returns { records: [{ id, title, data }], more } with at most 20 rows (10 by default); ctx.records.save(table, data) returns { saved, created }. A run may make up to 20 finds and 50 saves. See Records.