Docs / Getting started
Your first automation
An order arrives from a shop as a webhook. The script looks the customer up in a CRM, creates them if they are new, and answers with what it did. It is small on purpose — every piece of the platform you will use for the rest of your work appears in it exactly once.
Before you start you need the product running — on your own machine or on a box — and an account to sign in with.
If you are the first person here, there is no account yet: the server’s startup
log prints a one-time setup token (it also writes it into the data root),
and the sign-in page sends you to a setup form that takes it and creates the
first administrator. That first run also creates a tenant called Default,
with a sandbox and a production environment — so there is nothing to create,
only to claim (step 1).
Work in sandbox. Everything below is undone by deleting what you created.
1 · Sign in, and claim an environment
A fresh administrator has no access to anything yet — access is granted, never assumed, even for the person who installed it. The explorer says No environment yet and offers Create your first tenant, which opens the Administration area.
You already have a tenant there. Open Default and press Grant myself on
sandbox. (That page is also where each environment’s id is shown, which the
terminal section at the end needs.) Then Back to authoring.
The title bar now shows which tenant and environment you are in. Production looks different on purpose — a coloured band across the top, and a confirmation on anything that changes it — so you can always tell at a glance which one you are about to edit.
The first change you make takes the environment’s edit lock, shown in the same bar. One browser window edits an environment at a time; anyone else sees your name and reads along. Hand it back from the badge when you are done — a lock you are still holding is also what stops the command line from writing.
2 · The connection to the CRM
A connection is where a system lives and who you are to it. Create one:
New entity → Connection. It appears straight away, with a placeholder
name and its editor open; type CRM over the name at the top.
That name is the display name — what you see in the explorer. Everything outside the browser calls the entity something slightly different, and it is worth understanding once (below) rather than being surprised by it later.
The name a script types
Every entity has two names. You type the display name; the platform derives the name from it, and the name is the one that travels:
| You typed | The name | Because |
|---|---|---|
CRM | crm | lower case, hyphens for anything else |
Order webhook | order-webhook | the same rule |
Find contact | FindContact | a function’s name has to be callable in a script |
The name is what fetch({ connection: "crm" }) speaks, what a file in a folder
refers to another entity by, what a promotion to production matches the two
sides up with, and what a health signal or an alert names when something is
failing. The display name is a label; the name is the identity.
Where to see it. The entity’s header shows it under the display name, and
a9script entity list prints both — which settles it in one command when a
script cannot find something it is sure exists.
Rename an entity and the derived name follows, so anything that spelled out the old one — a script calling a function by name, a file on disk — is yours to update.
Fill in three things:
- Base URL —
https://crm.example.com/v2, the address every call is made relative to. - Outgoing authentication — how you prove who you are. Pick API key,
leave Where the key goes on A request header, give the Header name
the far end expects and paste the API key. If your far end documents its
key as part of the address instead — something like
?api_key=…— choose A query parameter and name it there; the platform adds it to every call, so the key never has to be typed into a call itself. - Leave everything else alone for now.
Press Activate.
Activate is the only save. Everything you changed since the last one — this form, another form, a script, a trigger — is checked and written together, or nothing is. That is why there is no save button on each field, and why you can move between entities mid-edit without losing anything.
Two things about the key you just pasted. It is encrypted before it is stored, and it never comes back: reopen the form and you see a fixed mask, not your key. Leaving that mask alone keeps the stored key; typing over it replaces it. And no script can read it — a script names the connection, and the platform applies the credential when the call goes out.
3 · The door the order arrives at
The shop needs somewhere to POST to. Endpoints belong to a connection too:
create a second one, Shop, and this time use the Endpoints section →
Add endpoint → Path orders.
The form now shows the URL the far end will use:
/hooks/<your tenant>/sandbox/orders
(When you want to POST to it yourself,
samples/webhook.sh is that call with nothing to
remember.)
That address is public, so decide who may call it. Incoming authentication
on the endpoint is what a caller has to present — a shared secret, a signature
the sender computes, or a known sender’s own verification scheme. none is fine
while you are trying things out in sandbox and is not fine afterwards.
Activate again.
4 · The function that talks to the CRM
A function is one operation, configured once, in your own vocabulary.
Create one: New entity → Function, and name it FindContact.
For an HTTP function you say, under The call:
- Connection —
CRM; - Method and Path —
GETand/contacts; - a Query row: name
email, value{{email}}; - and, at the bottom under Parameters, Add parameter named
email— the{{email}}above is filled from it.
Then, under The answer:
- Accept these statuses —
200; - Take the value at —
contacts; - switch on Take the one record found (refuse if there are several);
- which reveals When nothing is found — choose Answer nothing — absence is an ordinary result.
That last block is worth the minute it takes. Because you declared it, the call
answers a contact — not an envelope you unwrap, not a status you check — or
null when there is none; any other status stops the run and says what came
back. The line at the top of the form keeps up as you type: it ends up reading
“Called from a script as FindContact({ email }) — answers the value at
contacts in the body. Only 200 is an answer.”
Press Test, put { "email": "ada@example.com" } in Arguments (JSON),
and read what comes out: the panel shows What was sent (with the credential
named, never shown) and exactly what a script would receive. Testing changes
nothing — it runs the definition in front of you, saved or not, and the request
it shows you is shown once and never stored.
Now make a second one, CreateContact: POST to /contacts, parameters
email and name, a JSON body of {"email":"{{email}}","name":"{{name}}"},
accepted statuses 200, 201, and Take the value at id.
5 · The script
New entity → Main script, named on-order. The editor opens with
your environment’s own functions in scope — start typing Find and
FindContact completes, with its parameters and what it answers.
const order = input.event.body;
const found = FindContact({ email: order.customer });
const contactId = found === null
? CreateContact({ email: order.customer, name: order.customer })
: found.id;
log("order matched to a contact", { order: order.id, contact: contactId });
return { order: order.id, contact: contactId };
Six lines, and none of them is about HTTP. No await, no client to build, no
token, no retry loop, no status check: FindContact answers a contact or
null, and if the CRM had answered something unexpected the run would have
stopped right there saying so.
Now say what starts it. In the Triggers section: Trigger type →
Webhook → Connection Shop → Endpoint orders → Add trigger.
Activate.
6 · Run it without leaving the page
The run panel has a Test button and a box marked Input (JSON → input.event.body). Paste an order into it:
{
"id": "o-1042",
"customer": "ada@example.com",
"items": [{ "sku": "A-1", "price": 20 }]
}
Press Test. You get the run: what it returned, what it cost, and every line
it logged — including the object you passed as log’s second argument. A test
runs your unsaved work, so you can try a change before you activate it.
7 · Then for real
curl -X POST https://your-runtime.example.com/hooks/Acme/sandbox/orders \
-H 'content-type: application/json' \
-d '{"id":"o-1042","customer":"ada@example.com","items":[]}'
The response comes back immediately — accepting an event and running the script
are two different things, and the caller is not kept waiting for yours. (If you
do want the caller to wait for an answer, that is a different door: an API
service trigger, and respond() in the script.)
Then open the run: the payload as it arrived, the calls that went out, the answers, the lines, the result. Everything in Reading what triggered you comes from what you see there.
The same thing from a terminal
Everything above is also a folder of files. This is how CI does it, and how an LLM assistant does it — it is the same authoring path, not a second one.
Mint a personal access token on your account page — Access tokens → New token → Full access. It is shown once; copy it now.
The command is a9script, and today you invoke it by pointing node at it: from
an installation, node --conditions=a9script-built <install>/apps/ui/cli/dist/main.js;
from a source checkout, npx tsx apps/ui/cli/src/main.ts. A shell alias makes
the rest of this page read as written:
alias a9script='node --conditions=a9script-built /opt/a9script/apps/ui/cli/dist/main.js'
a9script login --server https://a9script.example.com --token <pat>
a9script whoami --json # lists every environment you may reach, with its id
Hand back the edit lock first. A browser window that still holds it stops
the command line from writing — apply refuses, naming the holder. Release it
from the badge in the title bar.
Write the folder — one JSON file per entity, scripts in .js files beside them:
first-automation/
crm.json the connection to the CRM
shop.json the connection carrying the "orders" endpoint
FindContact.json the function
CreateContact.json
on-order.json the main script + its trigger
on-order.js …its source
_input.json a sample order — yours, never uploaded
A file names, it never numbers: no ids anywhere, and one entity refers to
another by name — the connection shown as CRM is
crm in a file.
{
"kind": "script_main",
"name": "on-order",
"codeFile": "on-order.js",
"config": {},
"triggers": [
{
"kind": "webhook",
"source": { "kind": "http_connection", "name": "shop" },
"sourceDetail": "orders"
}
]
}
Then the loop — check, apply, run:
a9script apply ./first-automation --dry-run --json
a9script apply ./first-automation --json
a9script run on-order --input _input.json --json
The commands name no environment: the token you logged in with is bound to one, and every command acts there. Working in another environment means logging in with that environment’s own token.
--dry-run validates everything and writes nothing; each issue names the file
and the field. apply upserts by name, so running it again is safe and only
touches what changed. run exits 0 only if the run actually succeeded.
If you are pointing an LLM assistant at this, hand it a9script authoring-pack — the whole model, the function list and the common mistakes,
fetched from the installation it is talking to.
What to read next
- The language — what you may write in that editor.
- Connections and functions — the other five kinds of function, and how to make a call safe to repeat.
- Reacting to failures — because nobody is watching at 02:00.