a9script

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, not get-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 under params; defaults fills 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, a null or 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 no params at all — passing the value is the call.
  • Only the two types that make an outbound call (http, llm) may carry timeoutMs, pauseOn, retry, idempotent, expect — a template or mapping has no call in flight, and the strict schema refuses dead config. answer is narrower still: http only, 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 — the http_connection this call goes through (see the folder model). The connection owns baseUrl and the credential.
  • method — one of GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
  • path — joined onto the connection’s baseUrl; {{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-After unless header names another; maxWaitMs caps 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 under status when 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: style nextLink, cursorParam, cursorBody or offset. The platform then walks the pages for you. cursorBody is 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, plus hasMorePath where 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 of null for 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). empty applies to every shaped answer: a null at the path is nothing found, which is what a mutation answering "issue": null is 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’s errors are.

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 expect beside an answer — and then answer.empty must be "null", because that is the only thing the status can mean once the envelope is gone. The platform admits exactly these:

    • 404
    • 410

    That is the get-by-id form: "expect": [200, 404] with "answer": { "path": "fields", "empty": "null" }.

  • Any OTHER non-2xx in expect is refused beside an answer: 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. variables is 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.path walks into data. The script holds the issue and reads issue.identifier; nothing in it mentions data, errors or a status.
  • A mutation that made nothing stops the run. GraphQL answers 200 with "issue": null and its reason under errors. That null is 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) and bodyMarkdown are templates; recipient lists split on commas.
  • account names the email_out entity 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 createPdf or readBlob answered, the function says what the file is called. inline makes 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 (apikey auth); the broker injects it — the prompt never sees the credential. Provider echo needs no connection and answers deterministically (for tests and demos).
  • Empty model selects 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:

typemethodit is a
httpGET, HEADread
httpPOST, PUT, PATCH, DELETE, OPTIONSwrite
llmwrite
emailwrite
textread
csvread
jsonmapread

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:

  1. the environment allows write probes (WRITE_PROBE_NOT_ALLOWED means it does not — only an operator can change that, so ask; do not retry), and
  2. the request acknowledges it: --acknowledge-write (WRITE_PROBE_NOT_ACKNOWLEDGED means 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
Rendered from docs/skills/a9script-authoring/reference/config-functions.md in the product's own repository, at build time. Found a problem on this page? Write to the address in the footer.