a9script

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:

  1. The base URL — the part every request shares (https://api.example.com/v2). Everything after it belongs to the operations.
  2. The auth scheme — where the credential goes. Map it to the connection’s outgoingAuth: a Bearer <token> header → bearer; a custom key header (X-Api-Key: …) → apikey with that header; username/password → basic; a token endpoint with client id/secret → oauth2.
  3. The operations the task needs — for each: method, path (note the placeholders: /orders/:id or /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.
  4. The API’s manners — does it rate-limit (a 429 with Retry-After)? Page its collections (a next link, a cursor, offset/limit)? Those become pauseOn and paginate, 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-line description — it is what the signature help shows a caller);
  • a JSON request body → the body template 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 → paginate with 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 answers 200 with errors and no data: name the path into data, 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.

Rendered from docs/skills/a9script-authoring/reference/from-api-docs.md in the product's own repository, at build time. Found a problem on this page? Write to the address in the footer.