Functions through the API¶
The concept behind tools is explained in Tools.
A function is code of your own, compiled to WebAssembly, that the model calls as one tool,
function__<name>. Core runs it inside Core itself, in milliseconds, with the model's arguments as
its input, and answers the JSON it writes back. A function never calls out: no clock, no randomness,
no network, no files. It reads its arguments and, when its version declares them, records from
your datastores that Core reads for it first; it changes records only by
returning the writes it wants, which Core applies for it. So the same input always gives the same
answer.
Use a function for what a model must never decide: the price of a cart line at its tier, a minimum order, how VAT is split. The model may explain a price; the number itself comes from your code. Use a script instead when your code needs files, packages or seconds of work.
Functions are written through the API only; the console has no screen for them yet.
What your key needs¶
The tooling capability: a function needs no computer. A function that
reads or writes datastores also needs datastores on the key that
calls it; a key without it is not offered the tool, and naming it is 422, as for any tool a key
may not have. Add files if you upload the module as a
file rather than in the request body.
Build a module¶
A module reads one JSON document on standard input and writes one JSON document on standard
output. The input is {"arguments": {...}}, the arguments the model sent, with "reads" beside
them when the version declares reads. The output must be a JSON object; a version that reads or
writes datastores writes {"result": {...}, "operations": [...]} instead
(Read and write datastores). Exit with code 0 when the call worked;
any other code is a failure.
In Rust, build for wasm32-wasip1:
use std::io::{self, Read, Write};
fn main() {
let mut input = String::new();
io::stdin().read_to_string(&mut input).unwrap();
let document: serde_json::Value = serde_json::from_str(&input).unwrap();
let quantity = document["arguments"]["quantity"].as_i64().unwrap_or(0);
let unit_cents = if quantity >= 10 { 900 } else { 1000 };
let answer = serde_json::json!({ "total_cents": quantity * unit_cents });
io::stdout().write_all(answer.to_string().as_bytes()).unwrap();
}
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
A module may use standard input, standard output (and standard error, which Core discards),
exiting, and reading its arguments and environment, which are always empty. It may also link the
clock and random numbers, as Rust's standard library does, but a call that actually asks for the
time or for random bytes stops with trapped and says which it asked for, the same way every time.
Pass the current time or a seed as an argument when you need one. In Rust, a HashMap with its
default hasher asks for random bytes when it is made; give it a fixed hasher, or use a BTreeMap.
Core refuses a module that imports files or sockets when you upload it, and the error names the
import.
Register a function¶
POST /functions creates the function and its version 1. Give the module in one of two ways:
module: the.wasmfile as base64 text, for a module under about 700 kB;module_file_id: the id of a file you uploaded withPOST /files, for a larger module up to 4 MiB. Your key needsfilesfor this.
curl -s -X POST "$CORE_URL/functions" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d @volume_tiers.json
{
"name": "volume_tiers",
"description": "The price of one cart line at its volume tier. Use it for every price you state.",
"parameters": {
"type": "object",
"properties": { "quantity": { "type": "integer", "minimum": 1 } },
"required": ["quantity"]
},
"output_schema": {
"type": "object",
"properties": { "total_cents": { "type": "integer" } },
"required": ["total_cents"]
},
"module": "AGFzbQEAAAAB..."
}
parametersis the JSON Schema the model's arguments must meet. Arguments that do not meet it never reach your module.output_schemais the JSON Schema your module's answer must meet; for a version withreadsorwrites, theresultinside it. An answer that does not is a failure, so the model never reads a malformed price.fuel_limitandmemory_limit_bytesare optional; see Limits.readsandwritesare optional; see Read and write datastores.
The answer is 201 with the function, including module_sha256, the fingerprint of the module you
uploaded. A module that is not WebAssembly, imports something Core does not offer, or has no
_start is 422 on module, saying what is wrong.
POST /functions/{id}/versions writes the next version with the same body, without name. A body
identical to the latest version writes nothing and answers 200. GET /functions,
GET /functions/{id}, GET /functions/{id}/versions and DELETE /functions/{id} work as for
scripts. Deleting a function a datastore names as its
write function is 409 naming the datastore, until that
datastore's write_function is cleared or changed.
Read and write datastores¶
A function that works on stored state, such as a checkout, declares what it reads and which datastores it may write. Your module never asks for a record: Core reads the declared records before the module runs, as the caller, and applies the writes the module returns.
Reads. reads is a list of at most eight, read in order:
{"kind": "record", "as": "cart", "datastore": "carts", "key": "arguments.cart"}reads one record; your module finds it atreads.cartas{"key", "data", "etag"}, ornullwhen there is none.{"kind": "records", "as": "stock", "datastore": "stock", "keys": "reads.cart.data.lines[*].sku"}reads a list of keys, at most 100;reads.stockholds one entry per key, in order,nullfor a key with no record.
key and keys are JMESPath expressions over {"arguments", "reads"}: the
call's arguments and the reads declared before this one. An expression that does not compile is
422 on its field when you write the version. One that gives null reads nothing; one that gives
something other than a key, or more than 100 keys, fails the call with invalid_arguments before
your module runs.
Core reads as the caller. A visitor's call reads her own cart and the shared product records;
another person's cart, and a record or datastore the key's policy rules do not let it see, read as
null, exactly as if they did not exist.
Writes. writes names the datastores the module may write, at most sixteen. Each must exist
and be one the calling key may use when the function is called. The module returns its writes as
operations in the shape of a batch, naming each datastore by
name; operations may be left out when there is nothing to write. A put replaces the whole
record, as a single write does. Core applies them all in one transaction, or none of them.
An operation on a key the call read is held to what was read, so a write never lands on a
record your module did not see. An operation that sets its own if_match or if_absent keeps
it in place of the first two rows below; the last row refuses the call even then:
| The call read the key as | A put |
A delete |
|---|---|---|
| a record | only if it is unchanged (if_match its etag) |
only if it is unchanged |
null |
only if there is still none (if_absent) |
skipped: answered deleted: false |
| a shared record, in a call for a person | refuses the call | refuses the call |
A call for a person writes her own records and may never change a shared one: such a call fails
with denied_by_policy and nothing is written. An operation on a key the call did not read is
applied as your module wrote it.
When a record the writes are held to changed between the read and the write, nothing is written,
and Core reads again and runs your module again, up to three more times. A condition your module
set itself that no longer holds reruns the same way. If it still does not hold after the fourth
run, the call fails with conflict and nothing is written. Any other refusal, such as data that
does not fit the datastore's schema, fails the call with executor_failed, naming the operation;
nothing is written.
A checkout¶
A checkout function reads the cart and the stock of every line, numbers the order, writes it,
lowers the stock and empties the cart. The datastores carts, stock and orders must exist,
and the key that calls it must reach all three.
{
"name": "checkout",
"description": "Places the order for a cart. Use it when the customer confirms.",
"parameters": {
"type": "object",
"properties": { "cart": { "type": "string" } },
"required": ["cart"]
},
"reads": [
{ "kind": "record", "as": "cart", "datastore": "carts", "key": "arguments.cart" },
{ "kind": "records", "as": "stock", "datastore": "stock", "keys": "reads.cart.data.lines[*].sku" }
],
"writes": ["orders", "stock", "carts"],
"module": "AGFzbQEAAAAB..."
}
Called with {"cart": "cart-9"}, the module reads this on standard input:
{
"arguments": { "cart": "cart-9" },
"reads": {
"cart": {
"key": "cart-9",
"data": { "lines": [{ "sku": "TSHIRT-RED-M", "quantity": 2, "price_cents": 1900 }] },
"etag": "\"dsr_01J….3\""
},
"stock": [{ "key": "TSHIRT-RED-M", "data": { "left": 4 }, "etag": "\"dsr_01J….4\"" }]
}
}
and writes this on standard output:
{
"result": { "placed": true, "total_cents": 3800 },
"operations": [
{ "op": "counter", "datastore": "orders", "counter": "orders" },
{ "op": "put", "datastore": "orders", "key_from": { "operation": 0, "prefix": "O-" },
"data": { "lines": [{ "sku": "TSHIRT-RED-M", "quantity": 2 }], "total_cents": 3800 },
"if_absent": true },
{ "op": "put", "datastore": "stock", "key": "TSHIRT-RED-M", "data": { "left": 2 } },
{ "op": "delete", "datastore": "carts", "key": "cart-9" }
]
}
The counter hands out the next order number and the order is keyed O-<number>. The stock and
cart operations name keys the call read, so Core holds them to the etags it read: two customers
checking out at once never both take the same stock. The second call reads again, computes from
the stock the first one left, and can answer that an item is gone when nothing is left.
Run a checkout with your backend's key. The stock is shared: every visitor reads the same
records. A call made for a person, from a conversation with a visitor or with a subject token, may
not change a shared record, so its stock write fails the call with denied_by_policy. Call the
checkout with your backend's own key, which names nobody, and keep the carts as shared records
with a key per visitor, as here.
For a version with reads or writes, the answer's text holds Core's
{"result", "operations"}: your result, and what each operation did, so you learn the order
number:
{
"result": { "placed": true, "total_cents": 3800 },
"operations": [
{ "op": "counter", "datastore": "orders", "key": null, "etag": null, "created": null, "value": 7, "deleted": null },
{ "op": "put", "datastore": "orders", "key": "O-7", "etag": "\"dsr_01J….1\"", "created": true, "value": null, "deleted": null },
{ "op": "put", "datastore": "stock", "key": "TSHIRT-RED-M", "etag": "\"dsr_01J….5\"", "created": false, "value": null, "deleted": null },
{ "op": "delete", "datastore": "carts", "key": "cart-9", "etag": null, "created": null, "value": null, "deleted": true }
]
}
In a conversation the writes happen when the model calls the function, not when the turn ends.
Limits¶
| Limit | Default | What a call that reaches it answers |
|---|---|---|
| Fuel, roughly one unit per instruction | 100,000,000 per call; a version may ask up to 1,000,000,000 | fuel_exhausted |
| Memory | 16 MiB per call; a version may ask up to 64 MiB | memory_exceeded |
| Time | 2 seconds per call, whatever fuel is left | timeout |
| Answer size | 1 MiB | invalid_response |
| Module size | 4 MiB | 422 on upload |
| Reads per version | 8 | 422 when written |
Keys one records read names |
100 | invalid_arguments |
| Records the reads hand your module | 1 MiB together | invalid_arguments |
| Datastores a version writes | 16 | 422 when written |
| Operations one call returns | 100 | invalid_response |
Two reads with the same as, or a datastore named twice in writes, is 422 when the version is
written.
Fuel is what you write against: it counts the same on every machine, so a call that fits on your laptop fits in production. The deployment's operator may set other values.
Try it¶
Call the function directly, with the arguments a model would send:
curl -s -X POST "$CORE_URL/tools/function__volume_tiers/call" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"arguments": {"quantity": 12}}'
The answer is the tool_call object of Call a tool directly: text
holds the JSON your module wrote (for a version with reads or writes, Core's
{"result", "operations"}), and error_code says why a call failed:
error_code |
Means |
|---|---|
invalid_arguments |
the arguments did not meet parameters, a read's expression gave no usable key or too many, or the records read came to more than 1 MiB; your module did not run |
trapped |
your module stopped on a fault, such as a panic or a division by zero, or asked for the time or random bytes |
fuel_exhausted |
your module ran out of fuel |
memory_exceeded |
your module ran out of memory |
timeout |
your module ran out of time |
invalid_response |
the answer was too large, not a JSON object, or did not meet output_schema; or, for a version with reads or writes, it was not {"result", "operations"} or it had a field besides result and operations, an operation named a datastore not in writes or one the key does not reach, or an operation is not one a batch takes; nothing was written |
executor_failed |
your module exited with a code other than 0, too many calls were running at once, or an operation was refused; nothing was written |
conflict |
records the call read kept changing before its writes applied, four runs in a row; nothing was written |
denied_by_policy |
a call for a person tried to change a shared record it read, or an older version you pinned reads or writes datastores and the key has no datastores; nothing was written |
Add "version": 1 to run an older version.
Offer it to the model¶
Name it in a request's tools as {"type": "tool", "name": "function__volume_tiers"}, in an
agent, in a toolset (function__* takes every function) or under an alias, as any
other tool. Policy rules over tool decide on its name.
Usage and audit¶
- Each call writes one
tool_invocationusage record namedfunction__<name>. - The same arguments to the same version give the same answer, so Core may answer a repeat
from memory without running your module again. A version with
readsorwritesalways runs. - Each write leaves the audit rows a datastore write leaves.
- The audit trail records
function.created,function.version_created,function.updated,function.deleted, and per callfunction.calledwith the version, fingerprints of the input and the answer, how the call ended, the fuel it used, how many times it ran and how many operations it applied. Arguments, records read and answers never reach the audit trail.