CoDNA Request access
Contents

Docs Decisions

0028 · A script can call the APIs it reads: any method, a path beneath them, the headers they need

Status: Accepted · 2026-11-01. It extends the http connections of 0025, which today are only read, and the actions carried out after a run (0023). The sandbox (0007) does not change.

Context

With 0027, another app can start an automation, and the automation can read other systems before it runs. What it can't do is change anything in them. A script reaches outside in four ways:

  • A post goes to a webhook, Slack or a Claude routine: always a POST, with a JSON body, to the one address the connection was made with. It is sent after the run, retried, and the answer is only checked for success.
  • A read of an http source is a GET, or a POST with a body, to a path under its address.
  • Email goes to members.
  • A page is written in CoDNA.

So the flows people now ask for stop halfway:

  • Stripe says an invoice is paid, and the deal in HubSpot should move to won. That is a PATCH to /crm/v3/objects/deals/{id}.
  • A form is filled in, and a contact should be made in the CRM, a row added to Airtable, or an issue opened in Linear or Jira. Each is a POST to a path the connection doesn't have.
  • A release is published, and the Notion page should get a line. Notion wants a PATCH, and a Notion-Version header besides the token.
  • A ticket is closed, and its label should go: a DELETE.

Each needs another method, a path under the API's address, and often a second, plain header. Today a team sends these to n8n or Zapier, or doesn't do them.

The pieces are mostly there:

  • An http connection is already an API under an address, with a sealed secret header, owned and shared for use (0025). Its reads already take a path and a query.
  • Actions after the run already exist: queued in the run's transaction, carried out by the outbox, retried, recorded on the run, refused in a dry run with a reason (0023).

Decision

An API connection may be written to, when its owner says so

An http connection gets one more setting: the methods scripts may send it, from POST, PUT, PATCH and DELETE. It is none by default, so every http connection that exists today stays read-only. Its owner chooses on the Connections page, and management for the instance's.

  • Sharing doesn't widen it. Those it is shared with may send what its owner allowed, and nothing else.
  • The list is shown to everyone who may use it, and to management. A change to it is audited.
  • The token is the API's. A call is made with the connection's secret, which may be able to do more than the person whose script makes the call. The Connections page says to give the connection a token with only the scopes its automations need.

actions.call: a request to it, after the run

actions.call('hubspot', {
  method: 'PATCH',
  path: '/crm/v3/objects/deals/' + deal.id,
  body: { properties: { dealstage: 'closedwon' } },
})
  • method is one the connection allows. path and query follow the rules a read follows: a path under the connection's address and never above it, the address's own query kept.
  • The body is body, sent as JSON, or form, sent form-encoded as Stripe's and Twilio's APIs want. Either at most 256 KB.
  • headers adds up to 10 of the call's own, such as Idempotency-Key or If-Match. A call can't set a credential (Authorization, Cookie, Proxy-Authorization, or the connection's secret header) or what CoDNA sets itself (Host, Content-Type, Content-Length).

Like a post, a call is proposed in the run, vetted as the automation's owner, and made after the run commits. If the run fails, nothing is called.

The connection's own headers

Besides its one secret header, a connection may have up to 10 plain headers, sent with every read and every call, such as Notion-Version: 2022-06-28 or Accept: application/vnd.github+json. They are not secret: anyone who may use the connection sees them. A read may also add headers of its own, as a call does.

Calls are made in order, and sent again only when that is safe

  • A run's calls to one connection go in the order the script made them, one after another. An API often needs a thing made before it is changed. When one fails for good, the calls after it are not made, and each says why. Calls to different connections don't wait for each other.

  • Each is given 30 seconds, as a read is.

  • A call is sent again only when that can't apply it twice:

    • it never reached the API (no connection was made);
    • the API said to wait (429, or 503);
    • the method is PUT or DELETE, which the API should treat the same however often it comes;
    • or the call carries an Idempotency-Key, which tells the API to ignore a repeat.

    Otherwise a POST or PATCH that timed out, or answered 5xx, is not sent again. Its record says it may have been applied.

  • At most 20 calls a run, within the 50 actions a run may propose. Every address rule a post follows holds: https, a public address (or a private one where the instance allows sources there), the allowed hosts, and no redirects.

What is kept

  • Each call is kept on its run: the method, the path and query, what it sent (cut at 64 KB), what the API answered (its status, and the answer cut at 16 KB), and how long it took. The answer is cleared after 30 days, as a read's is. The run's page and automation_runs show them, so a wrong change in another system can be traced to the run that made it.
  • The body as sent is the JSON the script gave, kept as Postgres's jsonb: the same JSON, its keys perhaps in another order and its spacing tidied, which no API should mind. (Noted when it was built.)
  • The answer reaches no script. A call is like a post: what it answered is recorded, and nothing acts on it. A script reads what it needs before it runs, with inputs.

Trying it

  • A dry run calls nothing. It shows each call it would make: the method, the full path, the body and the headers, and whether the connection allows it, or the reason it would be refused.
  • A preview doesn't call either. It reads and asks for real (0026), and lists the calls as a dry run does. A preview changes nothing outside CoDNA.
  • A run by hand calls for real, like any run.

Where it shows

  • The Connections page: the methods allowed and the plain headers, on an http connection.
  • list_connections: each API connection's allowed methods and headers, and the call as a script writes it.
  • The script contract gets actions.call.
  • The console: management's view of people's connections shows what each lets scripts send.
  • The CLI: codna connections sets them for the instance's.

Consequences

  • Automations can change and delete data in other systems. That is the point, and the cost: a script with a mistake, switched on, can make the same wrong change on every event. Four things bound it. Writing is off until a connection's owner allows the method. A dry run shows every call before it is switched on. Every call is recorded with the run that made it. The API's own token scopes still apply.
  • A shared connection lets others write with its owner's token. Sharing one that allows writes is a bigger grant than sharing one that is only read. The page says so when the owner shares it.
  • A call that may have been applied is not retried, so a POST that timed out leaves its outcome to be checked by hand in the other system, unless the script gave an Idempotency-Key.
  • A run's calls to one connection take as long as they add up to, in the outbox, not in the run. The run itself stays at 100 ms.
  • This closes most of what n8n is used for alongside CoDNA: an app tells CoDNA (0027), the script reads what it needs, decides, and writes back. What is left is a flow that acts on a call's answer within the same run, which stays open below.

Alternatives rejected

  • fetch() inside the script, answering as it runs: what 0023 and 0025 rejected it for still holds. A run's 100 ms, safe dry runs, and secrets kept out of the sandbox.
  • A method and a path on actions.post, for webhooks: a webhook is one address that takes events, and allowing paths under it would make every webhook writable anywhere on its host. An API connection is already an address with paths beneath it.
  • A new kind for writing, apart from http: two connections, and two copies of one token, for one API.
  • Writing allowed by default on http connections: every read-only source shared with a team today would silently gain DELETE.
  • Headers with credentials set by the script: a secret would sit in a script.
  • Calls made side by side: an API often needs one before the next.

Settled when it was accepted

Tornike accepted it as proposed on 2026-11-01, settling:

  1. The action is actions.call.
  2. The limits: 256 KB a body, 10 headers, 20 calls a run, 30 seconds each.
  3. A POST or PATCH that may have arrived is not sent again, unless it carries an Idempotency-Key.
  4. Each answer is kept, cut at 16 KB, for 30 days.

Still open

  • Acting on a call's answer: "make the contact, then add its id to a list" needs the first call's answer in the next step. One way is an automation started by a call's answer, as one is by a request (0027).
  • OAuth sign-in, for Google's, Microsoft's and HubSpot's APIs, whose tokens expire and are refreshed.
  • Bodies that are neither JSON nor a form: a file upload, XML.
  • Signing outgoing calls, such as AWS's SigV4.

Building it

  1. The call:
    • the methods and plain headers on a connection;
    • actions.call, vetted and queued in the run;
    • the deliverer making a run's calls in order, with the retry rule;
    • the record on the run;
    • the dry run and the preview;
    • the Connections page, the console, list_connections and the contract, and the CLI.
  2. Docs: automations.md, the set-up-an-automation skill, and an example of a flow from an incoming request to a call.