Skip to content

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 .wasm file as base64 text, for a module under about 700 kB;
  • module_file_id: the id of a file you uploaded with POST /files, for a larger module up to 4 MiB. Your key needs files for 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..."
}
  • parameters is the JSON Schema the model's arguments must meet. Arguments that do not meet it never reach your module.
  • output_schema is the JSON Schema your module's answer must meet; for a version with reads or writes, the result inside it. An answer that does not is a failure, so the model never reads a malformed price.
  • fuel_limit and memory_limit_bytes are optional; see Limits.
  • reads and writes are 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 at reads.cart as {"key", "data", "etag"}, or null when there is none.
  • {"kind": "records", "as": "stock", "datastore": "stock", "keys": "reads.cart.data.lines[*].sku"} reads a list of keys, at most 100; reads.stock holds one entry per key, in order, null for 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_invocation usage record named function__<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 reads or writes always 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 call function.called with 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.