Docs / Connections & config functions
From an API description to a function set
The task you will usually be given: “here is the documentation of an API — build the functions for it”, with a URL (a docs page, an OpenAPI file, a Postman collection). This page is the recipe. The shapes it uses are in config-functions.md; the loop is in the main skill page.
0 · Understand the task before the API
Read what the integration is supposed to do, not what the API can do. An API with eighty operations usually serves an integration that needs five. Make sure you know:
- the goal (sync orders? send invoices? enrich contacts?) — it decides which operations you need;
- the environment id you are building into, and that a PAT is stored;
- where the credential will come from (you will not put it in a file).
If any of these is missing, ask — a wrong guess here multiplies through every file you write.
1 · Read the API description
From the docs, extract exactly four things:
- The base URL — the part every request shares
(
https://api.example.com/v2). Everything after it belongs to the operations. - The auth scheme — where the credential goes. Map it to the connection’s
outgoingAuth: aBearer <token>header →bearer; a custom key header (X-Api-Key: …) →apikeywith thatheader; username/password →basic; a token endpoint with client id/secret →oauth2. - The operations the task needs — for each: method, path (note the
placeholders:
/orders/:idor/orders/{id}become/orders/{{id}}), required query/header values, the request body shape, what the answer looks like, and the error statuses the docs promise. - The API’s manners — does it rate-limit (a 429 with
Retry-After)? Page its collections (anextlink, a cursor, offset/limit)? Those becomepauseOnandpaginate, not script code.
2 · One connection per API
{
"kind": "http_connection",
"name": "example-api",
"displayName": "Example API",
"config": {
"baseUrl": "https://api.example.com/v2",
"outgoingAuth": { "type": "apikey", "header": "X-Api-Key", "apiKey": "" }
}
}
The credential field stays blank in the file — it is entered once in the UI, by a person, and every later re-apply preserves what is stored. Your token cannot post a secret value; a file carrying one is refused. Endpoints on the connection are only for the reverse direction (the API calling you with webhooks); a pure client connection has none.
3 · One function per operation, named as the domain speaks
The function name is what scripts will say — name the business operation,
not the HTTP route: getOrder, searchCustomers, createInvoice. For each
operation from step 1:
- path/query/header placeholders → declared
params(with a one-linedescription— it is what the signature help shows a caller); - a JSON request body → the
bodytemplate with{{param}}at the value positions; - constants a caller may override (a page size, a locale) →
defaults; - documented rate limiting →
pauseOn: { "status": [429] }; - a safe-to-repeat operation (GET, PUT-by-id, or a create the far end
deduplicates) →
idempotent: true; - a collection listing →
paginatewith the style the docs describe; - the statuses that ARE an answer →
expect, and where the operation fetches or creates ONE record, the path to it →answer. A get, a find, a create all have one good answer and one bad one; say so here and the script holds the record and writes no status check. A GraphQL API answers200witherrorsand nodata: name the path intodata, and a failed mutation stops the run on its own.
{
"kind": "config_function",
"name": "createInvoice",
"displayName": "Create invoice",
"config": {
"type": "http",
"connectionName": "example-api",
"method": "POST",
"path": "/invoices",
"body": "{ \"customerId\": \"{{customerId}}\", \"lines\": \"{{lines}}\", \"currency\": \"{{currency}}\" }",
"pauseOn": { "status": [429] },
"expect": [201],
"answer": { "path": "invoice" },
"params": [
{ "name": "customerId", "description": "the customer to invoice" },
{ "name": "lines", "description": "the invoice lines, as a list" },
{ "name": "currency", "description": "ISO currency code" }
],
"defaults": { "currency": "EUR" }
}
}
A {{param}} in a body is filled on the parsed tree — a parameter whose
value is a list or object (like lines) arrives as that value, not as a
quoted string, and a value can never restructure the request.
Do not build one generic callApi function taking a method and a path. The
whole point is that each operation is individually named, individually
testable, and individually changeable without touching a script.
4 · Lint, apply, probe
Write the folder, then the loop from the skill page: apply --dry-run until
clean, apply, then probe each function on its own:
a9script probe getOrder --input '{"id":"A-1"}' --json
The answer holds the request as sent and the far end’s own response (see
“Probing a function headlessly” in
config-functions.md). Probe the error paths too: a
missing id shows you which status means “not there”. If it is one of the
not-found statuses (the list is in config-functions.md, under answer), list
it in expect beside "empty": "null"; any other status stays out of
expect, and the run then stops for you with the far end’s own words. Probe
the READS first and as many as you like; a function that WRITES is gated, and
the recipe below is written to need almost no writes at all.
Four probe lessons from testing this recipe against a live API:
- Derive ids from real answers. A get-by-id probe fed with an id taken from the list probe’s own answer proves the path template AND the API’s actual key format in one run; an invented id proves neither.
- The far end’s own bugs are answers, not your failures. A live API
answered every single-entity read with its own 500 while the list worked —
the probe reports that status and moves on. When a get-by-id misbehaves,
the workaround is usually a filtered list (
$filter=Id eq {{id}}in OData): same data, different serializer. - Let the shape teach you. Before writing field logic (a status check, a done-detection), probe once and read the keys of a real record — the docs’ field names and the wire’s field names are not always the same thing.
- Probe reads, then write the script. The reads give you every shape the integration needs; the first WRITE against a live system is better made by a run somebody asked for than by a probe you fired to see what happens.
5 · Sync integrations: two rules the first version always gets wrong
Keep no registry by hand. Linking records across two systems is what the
platform’s mapping store is for: setMapping, getMapping, listMappings
(fetch authoring-pack --part functions-mapping). A namespace per entity
kind, enumerable, durable, unbounded — do NOT rebuild it from getState
plus a hand-rolled key array, which is capped, race-prone, and yours to
maintain forever. getState/setState are for single small values:
watermarks, cursors, flags.
Walk a namespace with offsets, and never delete while you walk.
listMappings answers pages ordered by source id. A deleteMapping inside the
walk moves every later row up one, and the next page then skips as many as you
removed. Do it in two passes: walk the namespace and collect the source ids you
are finished with, and only then call deleteMapping for each of them.
Left without a page, listMappings answers up to a thousand pairs; ask for
{ offset, limit } when a namespace may hold more.
One call per mapping is a call per row. A poll that asks the other system
about every linked record makes those calls one after another, and a far end
that starts refusing parks the run once per call. Ask for them together where
the API allows it (one query with a list of ids), or stage the pairs with
dsPutMany and let dsEnrich make the lookup per row — one call after another,
keeping its place across a pause and never repeating a lookup it already made
(authoring-pack --part functions-dataset). Where the far end pages its
answers, configure paginate on the function and read the whole collection
with dsFetchInto instead of writing the loop.
Filter your own echo at the DOOR, not in the script. A bidirectional sync
WRITES into a system whose events it also LISTENS to: the comment your
integration posts comes back to it as a webhook, and unfiltered that writes
again — noise at best, a loop at worst. The endpoint carries ignore rules
for exactly this, and an event they match never starts a run at all:
"identity": "5f2e:acct-id-of-the-bot",
"endpoints": [
{
"path": "jira",
"incomingAuth": { "type": "signed", "profile": "jira", "secret": "" },
"enabled": true,
"ignore": [
{ "path": "comment.author.accountId", "equalsIdentity": true },
{ "path": "comment.body", "startsWith": "[J2L]" }
]
}
]
Two rules, use both: equalsIdentity compares the field to the connection’s
own identity — who the integration IS in that system, stated on the
connection and never guessed — and startsWith matches the marker your own
outbound template writes, which survives the day somebody changes the bot
account. A rule naming the identity of a connection that states none is
refused, because it would match nothing and say so nowhere. The connection is
a credentialed kind, so ask for both by name (see the skill page’s list).
6 · What to hand back
Name the connection and each function you created, what operation it covers, and the probes that prove them — including which functions you could NOT verify (no credential yet, sandbox API unreachable, a write the environment does not allow you to fire) and what is needed to finish. An applied-but-never -probed function set is not done.