Docs / Connections & config functions
Config functions — the six types and their files
A config function is an operation configured once and then called from any
script by its name, like a plain function: getOrder({ id: 7 }). The
platform does the managed part — for a call, the connection and its
credential, retries, and the wait when the far end says “too fast”; the script
says only what it wants.
Rules that hold for every type:
- The name is what scripts call, so it must be a plain identifier
(
getOrder, notget-order), and it may not shadow a name the platform already gives every script. - A parameter is a value.
{{param}}placeholders may sit in values (path segments, query values, header values, body values, template text) — never in a key, and never choosing a connection or mailbox. Declare every parameter underparams;defaultsfills the ones a caller omits. A call passing an undeclared name is refused, not ignored. - A placeholder may read into the value it names, with the same dot path a
script writes:
{{author.displayName}},{{lines[0].sku}}. The first part is the declared parameter. In a path, a query or a template the leaf must be text, a number or a boolean — an object, anullor a step that is not there stops the call by name; at a JSON body position the walked value lands whole, so{{ids}}sends a list as a list. It is a path, not an expression: no wildcard, no calls. - The two mappings (
csv,jsonmap) take ONE positional value and declare noparamsat all — passing the value is the call. - Only the two types that make an outbound call (
http,llm) may carrytimeoutMs,pauseOn,retry,idempotent,expect— a template or mapping has no call in flight, and the strict schema refuses dead config.answeris narrower still:httponly, because it shapes a response body and a model’s answer is already read for you.
http — one call through a connection
{
"kind": "config_function",
"name": "getOrder",
"displayName": "Get order",
"config": {
"type": "http",
"connectionName": "crm",
"method": "GET",
"path": "/orders/{{id}}",
"params": [{ "name": "id", "description": "the order id" }]
}
}
connectionName— thehttp_connectionthis call goes through (see the folder model). The connection ownsbaseUrland the credential.method— one of GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.path— joined onto the connection’sbaseUrl;{{param}}values are encoded into their segment.query,headers— string-to-string maps; the values may be templates.body— the request body as JSON text with{{param}}inside it. It is filled on the parsed tree, never spliced as text, so a parameter value can never restructure the request.timeoutMs— the call budget.pauseOn—{ "status": [429] }turns a rate limit into a real suspend: the run pauses for the server-named wait (Retry-Afterunlessheadernames another;maxWaitMscaps it) and continues. Set it for every API that rate-limits.retry— what the platform may repeat on its own:times(how many times the call may be made again),status(added to the default set),never(taken out; beats everything). The default set follows the METHOD: a GET, PUT or DELETE is repeated on a 5xx and on 408/425/429; a POST or PATCH only on 408/425/429 — the far end may have done the work before it failed to answer, and a create made twice is worse than a run that stops. The same line runs through a dead connection: one that never reached the far end (refused, unresolvable) is retried for every call; one that died after the request may have left is retried only for a GET or an idempotent call. Name the status understatuswhen you know the far end rolls back on it.idempotent: true— declares that making this call again has no second effect. It widens the repeat set to a GET’s, and a run whose call was in flight when its node died is re-suspended instead of failed. Only for calls that truly are: a GET, a PUT-by-id, a create the far end deduplicates.paginate— for a call that answers a collection a page at a time: stylenextLink,cursorParam,cursorBodyoroffset. The platform then walks the pages for you.cursorBodyis the GraphQL shape: the cursor goes back in the POSTed body, so the block names one of the call’s own parameters (cursorParam) whose{{placeholder}}the body writes, plushasMorePathwhere the far end says whether there is more — a Relay connection sets its cursor on the last page too, so the cursor alone cannot end the walk. Give the parameter a default ofnullfor the first page.
"body": "{ \"query\": \"<your GraphQL query, taking $after as its cursor variable>\", \"variables\": { \"after\": \"{{after}}\" } }",
"params": [{ "name": "after" }],
"defaults": { "after": null },
"paginate": {
"style": "cursorBody",
"itemsPath": "data.issues.nodes",
"cursorPath": "data.issues.pageInfo.endCursor",
"hasMorePath": "data.issues.pageInfo.hasNextPage",
"cursorParam": "after"
}
expect— the statuses this call calls an ANSWER, e.g.[200]. Any other answer stops the run where the call was made, saying the status, what the far end sent, and how often the platform had already asked. Configure this and no script has to check a status.answer— what the answer IS, so the call reads as the thing it fetches:path(a dot path into the body —"value"for OData, resolved as a literal key first),single: true(the path names a collection and you want THE record: one unwraps, several stop the run),empty("refuse", the default, names what was searched for;"null"makes absence ordinary).emptyapplies to every shaped answer: anullat the path is nothing found, which is what a mutation answering"issue": nullis telling you, so by default that stops the run instead of handing your script a null. A path that leads nowhere at all is a different thing and always stops the run. The message says how far your path got ("data" is null) and never the far end’s body — read the body in the trace (inspect), which is where a GraphQL API’serrorsare.
Say what an answer is, and stop writing ceremony
Left alone, a call answers { status, body } — body already parsed when the
far end sent JSON, a non-2xx arriving the same way as something to branch on.
That is the general arm, and it is right when a script really does branch.
It is not right for the calls most integrations are made of. A find, a create, a lookup have exactly one good answer and one bad one, and repeating the check at every call site is how a business script fills up with things that are not business. Say it once instead:
{
"kind": "config_function",
"name": "findOrder",
"displayName": "Find order",
"config": {
"type": "http",
"connectionName": "crm",
"method": "GET",
"path": "/orders",
"query": { "$filter": "reference eq '{{reference}}'" },
"expect": [200],
"answer": { "path": "value", "single": true, "empty": "null" },
"params": [{ "name": "reference" }]
}
}
The script is then the business and nothing else:
const order = findOrder({ reference: input.event.body.id });
if (order === null) { return "unknown reference"; }
return order.id;
Two rules to know when you write these together:
-
A status that means the record is not there may be listed in
expectbeside ananswer— and thenanswer.emptymust be"null", because that is the only thing the status can mean once the envelope is gone. The platform admits exactly these:404410
That is the get-by-id form:
"expect": [200, 404]with"answer": { "path": "fields", "empty": "null" }. -
Any OTHER non-2xx in
expectis refused beside ananswer: it carries no value to shape. Leave it out and the platform stops the run for you, with the far end’s own words — which is what you wanted anyway.
expect and pauseOn may not name the same status: a status the run WAITS
for is asked again, so it is never an answer. And answer may not sit beside
paginate or a stored response — a paged call’s records are named by
paginate.itemsPath, and a stored answer is a file handle, not a value.
A body that is not JSON, and an answer too big for a run
Three settings, for the calls a JSON body cannot make.
bodyKind: "multipart" plus parts. Each part has a name, and either a
value (templated, like any other) or a blobParam naming a declared
parameter that carries a stored file’s handle — exactly one of the two, or the
save is refused. filename and contentType are optional and templated. The
boundary is the platform’s; do not write one.
{
"kind": "config_function",
"name": "uploadInvoice",
"config": {
"type": "http",
"connectionName": "crm",
"method": "POST",
"path": "/invoices/{{id}}/attachments",
"params": [
{ "name": "id", "description": "the invoice id" },
{ "name": "file", "description": "handle of the PDF to attach" }
],
"bodyKind": "multipart",
"parts": [
{ "name": "note", "value": "Invoice {{id}}" },
{ "name": "document", "blobParam": "file", "filename": "invoice-{{id}}.pdf", "contentType": "application/pdf" }
]
}
}
bodyBlobParam names the parameter whose stored file IS the whole body —
no form, no JSON. Use it where the far end wants the bytes and nothing else.
responseTo: "blob" puts a successful response into a stored file and
answers its handle instead of a value. An error page still comes back readable,
so a refusal is still something you can read.
In all three the script passes a HANDLE and never the bytes. createPdf,
readFile and a previous call’s responseTo: "blob" are where a handle comes
from.
A write says what it made — the GraphQL case
A mutation is the same shape, and GraphQL is where the contract earns the most:
one path, one method, and a 200 whatever happened, so the status alone tells
a script nothing.
{
"kind": "config_function",
"name": "createIssue",
"displayName": "Create issue",
"config": {
"type": "http",
"connectionName": "crm",
"method": "POST",
"path": "/graphql",
"body": "{ \"query\": \"mutation($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id identifier } } }\", \"variables\": { \"input\": { \"title\": \"{{title}}\", \"labelIds\": \"{{labelIds}}\" } } }",
"expect": [200],
"answer": { "path": "data.issueCreate.issue" },
"params": [
{ "name": "title", "description": "the issue's title" },
{ "name": "labelIds", "description": "the label ids to set, as a list" }
]
}
}
const issue = createIssue({ title: input.event.body.id, labelIds: ["lbl-7"] });
return issue.identifier;
Four things are worth reading twice.
- The query is text, the values are parameters.
variablesis an ordinary part of the body, so{{title}}sits at a value position like any other. - A list goes in whole.
{{labelIds}}is at a body position, so the value the caller passed lands as a list — never as a quoted string, and never able to restructure the query. answer.pathwalks intodata. The script holds the issue and readsissue.identifier; nothing in it mentionsdata,errorsor a status.- A mutation that made nothing stops the run. GraphQL answers
200with"issue": nulland its reason undererrors. Thatnullis the far end saying it created no record, so by default the run stops there rather than handing your script a null it would have to test for. Where making nothing is an ordinary outcome, say so:"empty": "null".
Two more the method decides for you. A POST is never repeated on its own after
a 5xx — the far end may have made the record before it failed to answer — so
set idempotent: true only if the mutation truly deduplicates. And a create is
a WRITE, so firing it ad hoc needs the environment’s permission and your
acknowledgement; probe the reads and let a real run make the first write.
text — a template answering a string
{
"kind": "config_function",
"name": "orderSummary",
"displayName": "Order summary",
"config": {
"type": "text",
"format": "markdown",
"template": "Order **{{id}}** for {{customer}}",
"params": [{ "name": "id" }, { "name": "customer" }]
}
}
format (text | markdown) states what the filled string is — nothing is
rendered or escaped here; the consumer decides what the string becomes.
Reach for a text function whenever a script would otherwise glue a sentence
together with +: a comment you post into another system, a subject line, a
status note. Three things follow. The words live in one file a person can edit
without touching the script. Every call writes the same marker prefix — the one
your endpoint’s ignore rule matches. And the script passes values instead of
building prose:
const order = input.event.body;
return orderSummary({ id: order.id, customer: order.customer });
Keep + for keys and paths. For a one-off string inside a script,
template("Hi {{name}}", { name }) fills flat {{key}} placeholders — it reads
no dot paths, and an object value is refused.
A placeholder may read into an object: {{author.displayName}},
{{lines[0].sku}}. In a text template the value there must be text, a
number or a boolean; a step that is missing or null refuses, naming the
placeholder — where the value may be absent, pass author.displayName ?? "Unknown" from the script. In a JSON body a leaf takes the walked value whole.
defaults are per parameter.
email — a whole message, sent by name
{
"kind": "config_function",
"name": "sendInvoiceMail",
"displayName": "Send invoice mail",
"config": {
"type": "email",
"to": "{{recipient}}",
"subject": "Invoice {{number}}",
"bodyMarkdown": "Hello {{name}},\n\nyour invoice **{{number}}** is attached.",
"attachments": [{ "param": "invoice", "filename": "invoice-{{number}}.pdf" }],
"params": [
{ "name": "recipient" }, { "name": "number" },
{ "name": "name" }, { "name": "invoice" }
]
}
}
- Envelope fields (
to,cc,bcc,from,replyTo,subject) andbodyMarkdownare templates; recipient lists split on commas. accountnames theemail_outentity that sends — a NAME, never a template: a parameter cannot choose which mailbox (and credential) sends. Left out, the environment’s single mail account is used.- An attachment IS a parameter: the caller passes what
createPdforreadBlobanswered, the function says what the file is called.inlinemakes it addressable from the body markdown by the parameter’s name. - The body is sent as BOTH rendered html and plain text.
A mail account may not be delivering
Before you conclude that a send worked, know what the account DOES with a message. Every mail account states one of three modes, and the same three exist in every environment:
deliver— send as authored. This is what an account with nothing configured does.redirect— send to the account’s own list of addresses instead of the authored recipients (To, Cc and Bcc alike). The original recipients travel in a header and the subject is prefixed, so a redirected copy is recognisable; the body is untouched.record— send nothing at all. The run’s log keeps what would have gone out.
The answer a send gives you always states the mode it was made under, so a
script never has to assume. Read it before reporting success: in a sandbox
wired to a real mail account, record and redirect are how the rest of the
integration is exercised without mailing a customer. The mode belongs to the
account, and changing it needs an operator.
csv — one columns table, both directions
{
"kind": "config_function",
"name": "orderRows",
"displayName": "Order rows",
"config": {
"type": "csv",
"columns": [
{ "column": "Order", "path": "id", "type": "string", "required": true },
{ "column": "Total", "path": "total.amount", "type": "number" },
{ "column": "Placed", "path": "placedAt", "type": "date" }
],
"options": { "delimiter": ";", "headerRow": true }
}
}
Hand it a list of records → it answers CSV text. Hand it CSV text → it answers
typed records. One table serves both directions, which is what keeps a file
you write readable by the same function later. length caps text columns
(options.overflow says whether over-long text truncates or fails).
jsonmap — a target structure, filled from a value
{
"kind": "config_function",
"name": "toCrmContact",
"displayName": "To CRM contact",
"config": {
"type": "jsonmap",
"template": "{ \"fullName\": \"{{name}}\", \"mail\": \"{{contact.email}}\" }",
"missing": "omit"
}
}
The template is JSON text whose {{path}} placeholders read from the ONE
value passed at call time (a dot path into it). missing decides what an
absent value does: write null (default) or drop its key. Given a list, it
maps each element.
llm — a prompt to a model
{
"kind": "config_function",
"name": "classifyTicket",
"displayName": "Classify ticket",
"config": {
"type": "llm",
"provider": "anthropic",
"connectionName": "anthropic-api",
"model": "",
"system": "You classify support tickets. Answer JSON only.",
"prompt": "Classify: {{text}}",
"responseFormat": "json",
"pauseOn": { "status": [429] },
"params": [{ "name": "text" }]
}
}
- The connection holds the API key (
apikeyauth); the broker injects it — the prompt never sees the credential. Providerechoneeds no connection and answers deterministically (for tests and demos). - Empty
modelselects the provider’s default. responseFormat: "json"parses the answer for you and fails loud when the model did not return JSON.- The answer is
{ text, json, model, usage, stopReason }— and it is untrusted input: text a model wrote, not a value the platform vouches for. Validate it in the script before acting on it.
Probing a function headlessly
Once a function is applied, fire it on its own and read the whole exchange:
a9script probe getOrder --input '{"id":"A-1"}' --json
The answer carries the run (what a script calling it would receive), the
trace of every call it made, and exchange — the one call in full: the
request as sent (the credential named, never valued) and every attempt
back, headers and body included. That is the fastest way to learn which shape
a function actually has, and the only way to see the far end’s own error page.
--input takes JSON inline (run --input takes a path — a probe is a
one-liner you type). A probe exits 0 only when the call answered; the
exchange is on stdout either way, which is the point.
A probe is a debug run, so it keeps payloads and works in a sandbox environment only.
Reads are free. Writes are not.
Every function has an operation class the platform derives from its own definition — you never choose it per probe:
| type | method | it is a |
|---|---|---|
http | GET, HEAD | read |
http | POST, PUT, PATCH, DELETE, OPTIONS | write |
llm | — | write |
email | — | write |
text | — | read |
csv | — | read |
jsonmap | — | read |
A model prompt spends money at the far end and a mail function sends, which is why neither is a read; the three mappings reach nothing at all.
When the method is the wrong witness, say so on the function itself:
"operation": "read" for the POST-shaped search every vendor has,
"operation": "write" for the GET that mutates. Say it — a search declared a
read is probed freely for the rest of the integration’s life.
Probing a read needs nothing. Probing a write needs two things at once, and the refusal names both:
- the environment allows write probes (
WRITE_PROBE_NOT_ALLOWEDmeans it does not — only an operator can change that, so ask; do not retry), and - the request acknowledges it:
--acknowledge-write(WRITE_PROBE_NOT_ACKNOWLEDGEDmeans the environment allows it and you did not).
Every write probe you are allowed is recorded with your name against it.
When NOT to probe: anything that writes into a system you were not told is safe to write into. Sandbox credentials often point at a real vendor account. Before asking for the flag, say plainly which call you want to fire, against which system, and what it will create or change — then let your operator decide. Reading first is almost always enough: harvest the shapes with reads, write the script, and let a real run make the first write.
Keeping a probe
A probe leaves nothing behind. When you want the check to survive — a regression test somebody else can run — apply a one-line main beside the functions and run it:
{
"kind": "script_main",
"name": "check-order",
"displayName": "Check order",
"codeFile": "check-order.js",
"config": { }
}
return getOrder({ id: input.event.body.id });
a9script run check-order --input _input.json --json