Documentation

Writing an extension

Version 1.0 ·Updated 2026-08-29

Next reviewed by 2027-02-28.

Written for developers building a plugin, and the administrators who decide whether to run one.

Read this first: what a plugin cannot do

Your plugin runs in a forked process with no database handle, no filesystem, no environment and no network. It reaches the school's record through one versioned host API, carrying exactly the capabilities an administrator at that school granted it. That is the design, and it is the part to build around rather than the part to work around.

The filesystem
Node's permission model, with read access to the plugin's own directory and nothing else. Writing anywhere is refused.
The database
There is no connection in the process. The driver and the ORM cannot even be loaded: they live outside the readable directory.
ataw's own source
Outside the readable directory as well, so there is nothing to import and walk back to a connection.
Secrets
The process is started with an empty environment. There is nothing in process.env to read.
The network
fetch is deleted and the network builtins are refused at import. Egress exists only through a granted capability and the endpoints your manifest declares.
Another process
child_process, worker_threads and native addons are all refused by the permission model.
Another school
The host binds the tenant before your code runs, and no argument you send names a school.
The clock and the heap
A run has a deadline and a heap ceiling. Exceeding either stops your plugin and leaves the app alone.

None of this is a promise about your code being trustworthy. It is what the host does whether or not it is. A school running your plugin can read every action it took, refused ones included, under your plugin's own name.

A plugin is a directory with two files

There is nothing to install and no SDK to depend on. A manifest describing what you want, and a JavaScript module exporting a function per contribution point.

roll-export/
  ataw-plugin.json
  index.mjs

The reference plugin is plugins/roll-export in the repository. It is exercised by the test suite on every change, so it is the working example rather than an illustration of one.

The manifest

Everything the host needs to decide whether to install your plugin is here, and none of it is discovered by running your code. That ordering is deliberate: an administrator sees what you are asking for before a line of your plugin has been loaded.

{
  "id": "roll-export",
  "name": "Roll export",
  "version": "1.0.0",
  "publisher": "ataw",
  "description": "A class roll as CSV.",
  "hostApi": "^1.0.0",
  "entry": "index.mjs",
  "capabilities": ["person.read", "affiliation.read", "export.write"],
  "contributes": {
    "export": [{ "id": "class-roll", "title": "Class roll (CSV)", "format": "csv" }]
  }
}

The manifest is refused with every reason at once rather than the first one, because a first-time manifest usually has three problems and fixing them one refusal per attempt is how people give up on an extension API. Two rules catch most authors out:

  • A contribution cannot outrun its capability. Contributing an export without asking for export.write is refused at install, rather than failing at 2am on the first run.
  • Egress needs a destination. Asking for network.outbound without listing endpoints is refused, because an administrator granting egress is agreeing to a destination and not to the idea of one.

Contribution points

A contribution point is a registry the host owns and your plugin appends to. Your plugin never mounts a route, patches a component or registers a hook: it declares an entry and the host decides where it appears. That is what stops a plugin repainting the status colours, which are the ones the design system spent a card making readable to a dichromat.

Export one function per point you contribute to, named for the point.

export

Lists the export on the exports screen and runs the plugin to produce the file when somebody asks for it.

Needs export.write

event

Delivers matching events to the plugin as they happen, one sandboxed run per delivery.

Needs event.subscribe

schedule

Enqueues the plugin on the school's job queue at the stated time, with the queue's own retry and backoff.

Needs job.schedule

finding

Runs the plugin as part of the nightly read and merges what it returns into that morning's findings.

Needs event.subscribe

nav

Adds an entry to the app navigation, pointing at a page the plugin contributes.

Needs export.write

Defined, and not yet built

These are part of the contract so you can design around not having them. A manifest contributing to one is refused, and the refusal names what it is waiting on.

import

Offers the adapter on the migration screen and hands it a staged file to interpret.

Waiting on #198 — inbound migration API: staging, dry runs and validation reports

panel

Renders the plugin's panel in a named slot on a dashboard, inside a host frame.

Waiting on #200 — a reusable visual component library, which owns the slots

studentTab

Adds a tab to the student record, rendered in a host frame beside the built-in ones.

Waiting on #200 — a reusable visual component library, which owns the slots

Capabilities

Your plugin holds nothing by default. It asks in the manifest, and an administrator at each school grants them one at a time. The second column is what that administrator reads at the moment they decide, so ask for the least you can work with.

person.read

Read names, dates of birth and the name history of people at this school.

The administrator is told The plugin can read every person on file, including children, and every former name they have been recorded under.

person.write

Create people and correct their names and dates of birth.

The administrator is told The plugin can change a child's recorded name or date of birth. Changes are attributed to the plugin, not to a person, and the record keeps both.

affiliation.read

Read whether someone is a student, staff member, applicant, volunteer, contractor or board member, and when that started and ended.

The administrator is told The plugin can build a full list of everyone attached to the school and work out who has left.

enrolment.read

Read applications and where each one has reached.

The administrator is told The plugin can see which families have applied and been declined, which is not information the families have agreed to share onward.

attendance.read

Read the attendance register and its explanations.

The administrator is told The plugin can see which children are absent and how often, which is a child-protection signal as well as an administrative one.

restricted.read

Read parenting orders, court documents, exit reasons and the merge history behind an identity decision.

The administrator is told The plugin can read the material a school shows only to named staff. Granting this to reach one field grants it for all of them.

export.write

Generate a file a person can download from the exports screen.

The administrator is told The plugin decides what goes in the file. It can only put in what its other capabilities let it read, and the file is recorded against the plugin.

event.subscribe

Receive an event when a record it can already read is created, changed or closed.

The administrator is told The plugin learns about a change within seconds rather than on its next run. It is told what changed, not what the new value is — reading that still needs a read capability.

job.schedule

Ask the host to run the plugin at a stated time, on the school's job queue.

The administrator is told The plugin runs when nobody is watching. Every run is subject to the same timeout and memory ceiling as a run somebody asked for.

network.outboundsends data outside

Make requests to the addresses listed in the plugin's manifest, and only those.

The administrator is told This is the capability that turns reading into disclosure. Anything the plugin can read, it can now send to the listed addresses. Grant it only where the destination is one the school has a relationship with.

Each capability carries a ceiling. A sensitive capability returns sensitive and internal fields and never a restricted one, and no combination of sensitive capabilities adds up to restricted.read. A field nobody has classified is not returned at all, so a new column in the record is invisible to plugins until somebody classifies it.

The host API

Version 1.0.0. Your handler receives { id, payload, host }. Every method returns a promise, and a method you have not been granted rejects with a sentence naming the capability rather than returning an empty list.

host.people.list

A page of people at this school, ordered by name. Soft-deleted records are not returned.

Takes { limit?: number, offset?: number }Needsperson.read

host.people.get

One person by id, or null where there is no such person in the working set.

Takes { id: string }Needsperson.read

host.affiliations.list

A page of affiliations — student, staff, applicant and the rest — newest first. `openOnly` is what “who is here now” means.

Takes { kind?: string, openOnly?: boolean, limit?: number, offset?: number }Needsaffiliation.read

host.log

Write a line to the plugin's own audit trail. Available to every plugin, because a plugin that cannot say what it is doing cannot be debugged by the school running it.

Takes { message: string }Needsnothing

export default {
  async export({ id, payload, host }) {
    const people = await host.people.list({ limit: 500, offset: 0 })
    await host.log(`Rolled ${people.length} records.`)
    return { filename: "roll.csv", contentType: "text/csv", body: toCsv(people) }
  },
}

Pages are capped by the host. A plugin that assumes otherwise exports the first page and looks like it worked, so page until a short page comes back.

Events

An event carries what changed and when, and never the new value. Reading that back needs a read capability, which keeps one place deciding what your plugin may see. Subscribing to a topic needs the capability that would let you read the thing that changed, because knowing when something happened is knowing something about it.

person.created
A person was added Needs person.read
person.changed
A person's details changed Needs person.read
person.deleted
A person was deleted Needs person.read
affiliation.opened
Someone became a student, staff member or applicant Needs affiliation.read
affiliation.closed
Someone's affiliation with the school ended Needs affiliation.read
enrolment.stage.changed
An application moved stage Needs enrolment.read
attendance.recorded
Attendance was recorded Needs attendance.read

Schedules

A scheduled contribution states its time and its zone. Cron is not accepted, because 0 2 * * * means 2am in whatever zone the server keeps, and a school in Melbourne running its overnight work at noon is a defect a receptionist finds.

"schedule": [
  { "id": "nightly", "title": "Nightly roll", "at": "daily 02:00 Australia/Melbourne" }
]

hourly, daily HH:MM <zone> and weekly <day> HH:MM <zone>. The zone is an IANA name and is checked at install. Daylight saving holds the local hour, so an overnight job runs once on the morning the clocks go back and at the instant the clock reaches on the morning its time does not exist.

Compatibility

Declare the host API range you built against. The host compares it against what it offers and refuses a plugin that will not work, before it runs, with both versions named. An ataw upgrade that moves the API out of your range marks the installation incompatible with that sentence stored on it, and moves it back when the range fits again.

  • Adding a method or a field is a minor. Write your plugin to ignore what it does not know.
  • Removing or changing one is a major, announced on this page for a full major first.
  • A plugin declaring ^1.0.0 keeps working across every additive change, which is the point of the caret.

Running it

# put your directory beside the others, then
pnpm run test:integration    # the suite runs the reference plugin end to end
pnpm dev                     # the server registers what it finds on boot

A plugin is registered on boot by reading its manifest and hashing its entry file, with nothing executed. It is then installed, granted and enabled per school at /app/settings/plugins, and none of that needs a deploy.

What is not built

  • No marketplace, and no signing. A plugin is a directory the operator puts on the server. There is no distribution channel and no publisher verification.
  • Client-side contributions are declared and not rendered. Panels and student-record tabs wait on the component library that owns the slots.
  • Egress is declared and not yet proxied. The capability, the endpoint list and the grant all work; the host does not yet make the request on your behalf, so nothing can leave today.
  • The record itself is small. People and affiliations exist. Attendance, assessment and wellbeing arrive with their domains, and the host API grows with them.

Getting in touch

api@seraco.io. If a contribution point you need is missing, or a capability is coarser than the thing you actually want, that is worth telling us before you design around it.