a9script

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 entityConnection. 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 typedThe nameBecause
CRMcrmlower case, hyphens for anything else
Order webhookorder-webhookthe same rule
Find contactFindContacta 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 URLhttps://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 endpointPath 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 entityFunction, and name it FindContact.

For an HTTP function you say, under The call:

  • ConnectionCRM;
  • Method and PathGET and /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 statuses200;
  • Take the value atcontacts;
  • 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 entityMain 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 typeWebhookConnection ShopEndpoint ordersAdd 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 tokensNew tokenFull 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.


Rendered from docs/guide/first-automation.md in the product's own repository, at build time. Found a problem on this page? Write to the address in the footer.