Skip to content
β—‡ AirawatOS Developer
🚧 Under development β€” this portal is being built track by track. Content will keep growing, and some sections are still placeholders.

Tutorials

Tutorial β€” build a real solution: Air Intelligence

The Quickstart shipped a "hello world." This walks through a real, running solution β€” Air Intelligence β€” so you see how the pieces fit on something that actually does public-interest work: sense air quality, turn readings into a forecast, recommend an advisory, and land it in a named official's inbox for a decision.

You'll recognise every idea from the Deep Dives β€” a driver that ingests data, an engine that derives a new layer, a skill that turns facts and rules into a cited recommendation, and the universal Inbox where a person decides. Here they're wired together into one solution.

This tutorial follows the actual Air Intelligence components in the platform (ds:airawat:solution/air-intelligence). The code excerpts are trimmed from the real source β€” enough to teach the pattern, not the whole file. Where the real system is richer than shown, it says so.

What we're building

The end-to-end loop:

   sense            derive              recommend           decide
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ driver β”‚ ──▢ β”‚   engine     β”‚ ──▢ β”‚    skill     │──▢│  Inbox   β”‚
 β”‚ air-   β”‚     β”‚ station-     β”‚     β”‚ air-quality- β”‚   β”‚ (a named β”‚
 β”‚ sensorsβ”‚     β”‚ interpolationβ”‚     β”‚ advisory     β”‚   β”‚ official β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚ decides) β”‚
 air.sensor_     air.quality          governance.        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   reading       (a derived layer)    recommendation      + a receipt

And an app (air-explorer) renders the layers and the recommendation on a map.

The solution manifest

A solution is a bundle that installs its members together. Air Intelligence's manifest lists the components above:

component_id: ds:airawat:solution/air-intelligence
kind: solution
version: 0.1.0
members:
  - ds:airawat:driver/air-sensors            # sense: readings β†’ governed facts
  - ds:airawat:engine/station-interpolation  # derive: readings β†’ air-quality surface
  - ds:airawat:engine/hotspot-detection      # derive: find the worst cells
  - ds:airawat:skill/air-quality-advisory    # recommend: facts + rules β†’ advisory
  - ds:airawat:engine/decision-router        # route the recommendation to the Inbox
  - ds:airawat:app/air-explorer              # see it on a map
  - ds:airawat:app/inbox                      # where a person decides

Install this and all the members come with it, wired together by their declarations. You'll build the interesting ones below.


Step 1 Β· The driver β€” readings into governed facts

A driver is the only sanctioned way outside data becomes governed state. air-sensors fetches PM2.5 from two public sources (India's CPCB network and OpenAQ) and writes one governed fact per reading.

Its contract (meta.yaml)

Everything the driver may touch is declared up front β€” this is its permission set:

component_id: ds:airawat:driver/air-sensors
kind: driver
version: 0.2.1
entrypoint: run.py
confinement_min: L1
reads:  [space.aoi]                 # the area of interest to ingest for
writes: [air.sensor_reading]        # the one governed type it may write
egress:                             # the only outside hosts it may call, with a rate cap
  - { host: api.openaq.org,  rate_per_min: 30 }
  - { host: api.data.gov.in, rate_per_min: 40 }
secrets: [OPENAQ_API_KEY, CPCB_API_KEY]   # keys, delivered at use β€” never in the code
config:  [SOURCE, MAX_LOCATIONS, AS_OF_DATE, RESOURCE_ID, PAGE_LIMIT]
sources:
  - { ref: "ds:airawat:ref/source/openaq", host: api.openaq.org }
  - { ref: "ds:airawat:ref/source/cpcb",   host: api.data.gov.in }
scopes:
  - { verb: read,  scope: space.aoi }
  - { verb: write, scope: air.sensor_reading }

A tenant can read this and know exactly what the driver does before installing it β€” and the platform enforces every line of it at runtime. See What your code can touch.

The code (run.py)

A driver reaches the world only through the broker β€” the single guarded door (see Zero-Trust). It never opens a socket, reads a key from disk, or writes to the database directly. Every action is a broker call carrying the run-token:

The write is where governance happens β€” a natural key (so re-running never duplicates), a space block, and provenance:

def _write(T, sid, lat, lng, res, vals, obs, source, station, method):
    cell = h3.latlng_to_cell(lat, lng, res)
    rid  = f"ds:{T}:air/sensor_reading/{sid}-{obs[:13]}"   # natural key = tenant + sensor + hour β†’ idempotent
    fact = {"schema": "air.sensor_reading", "id": rid, "sensor_id": sid,
            "space": {"h3": cell, "res": res, "centroid": [round(lng, 5), round(lat, 5)]},
            "pm25": vals.get("pm25"), "no2": vals.get("no2"), "o3": vals.get("o3"),
            "observed_at": obs, "source": source,
            "prov": {"method": method, "component": "ds:airawat:driver/air-sensors",
                     "station": station, "observed_at": obs}}
    _call("/v1/write", {"scope": "air.sensor_reading", "id": rid, "object": fact,
                        "valid_time": {"from": obs}})

And the fetch side β€” brokered egress, filter to the area of interest, and (importantly) write real data or nothing β€” never a synthetic fill:

def ingest_cpcb(T, bbox, res):
    w, s, e, n = bbox; offset = 0
    for _ in range(50):
        status, page = _fetch(f"{CPCB_BASE}?format=json&offset={offset}&limit={PAGE_LIMIT}", inject=CPCB_INJECT)
        if status != 200 or not page:
            return 0                                  # no readings β†’ write nothing, fail loudly
        for rec in page.get("records") or []:
            lat, lon = _num(rec.get("latitude")), _num(rec.get("longitude"))
            if lat is None or not (s <= lat <= n and w <= lon <= e):
                continue                              # keep only stations inside the AOI
            # …group rows by station, collect pm25/no2/o3…
    for st in stations.values():
        if "pm25" in st["vals"]:
            _write(T, f"cpcb-{st['slug']}", st["lat"], st["lon"], res, st["vals"], st["obs"], "cpcb", st["name"], "cpcb-caaqms")

main() resolves the tenant (from the broker's /v1/context β€” never a hardcoded city), loads that tenant's area of interest, and dispatches on the SOURCE config. That's the whole driver: config + AOI β†’ brokered fetch β†’ governed write, with provenance, idempotently.

The type it writes

air.sensor_reading is a governed schema β€” a shared, agreed shape (see The Shared Vocabulary). It's a point observation (a station at a place and time), with pm25/no2/o3, an observed_at valid-time, and a space block. Because it's governed, every other component β€” the engine, the app, another agency β€” reads it the same way. You build against these types; you don't invent private formats.

Try it: a second driver, openaq-archive, writes the same air.sensor_reading type as a historical backfill. Two drivers, one governed noun β€” that's how independent sources feed one clean layer.

Step 2 Β· The engine β€” readings into a derived layer

Stations are sparse points. An engine turns them into something usable: a continuous air-quality surface over the whole area. station-interpolation reads the readings and writes one air.quality value per map cell.

Its contract

component_id: ds:airawat:engine/station-interpolation
kind: engine
version: 0.1.0
confinement_min: L1
reads:  [space.aoi, air.sensor_reading]     # the readings the driver wrote
writes: [air.quality]                       # the derived surface it produces
scopes:
  - { verb: read,  scope: air.sensor_reading }
  - { verb: write, scope: air.quality }

An engine, like a driver, does all its I/O through the broker β€” it runs under a minted run-token and calls /v1/read, /v1/query, /v1/write. (No open sockets, no ambient credentials β€” same clean-room contract.)

The code β€” read, compute, write a derived fact

The engine reads station facts, interpolates onto H3 cells (inverse-distance weighting), and writes each cell with derived provenance β€” the method it used and the inputs it came from, so the value is reproducible and explainable:

rid  = f"ds:{T}:air/quality/{hcell}-{obs[:13]}"
fact = {"schema": "air.quality", "id": rid,
        "space": {"h3": hcell, "res": res, "centroid": [round(lng,5), round(lat,5)]},
        **out, "observed_at": obs,
        "method": "idw", "confidence": confidence,
        "prov": {"method": "idw", "confidence": confidence,
                 "derived_from": ["air.sensor_reading@stations"], "observed_at": obs}}
_call("/v1/write", {"scope": "air.quality", "id": rid, "object": fact,
                    "valid_time": {"from": obs}})

That prov block is the whole point of a derived fact: air.quality isn't a mystery number β€” it says "computed by IDW from the station readings." Trace it back and you reach the driver, the source, and the hour. See The Shared Vocabulary on provenance.

Live vs richer: station-interpolation is the simple, live baseline (Model-0). Air Intelligence also ships an ML path β€” an engine (pm25-features) that assembles many governed layers into a training matrix, and pm25-model that trains an XGBoost model (with a champion/challenger promotion gate) and writes air.quality with model provenance. Both write the same air.quality type, so everything downstream is unchanged β€” you can swap a better engine in without touching the app or the advisory. A per-hour forecast schema is planned; the shipped layer is the current nowcast.
On verbs: newer engines expose named actions through a verbs: map + a HANDLERS = {verb: fn} table (the governed verb-dispatch from Interaction). The air engines here use the simpler main() style; both are valid β€” pick verbs when other components need to call your engine by name.

Step 3 Β· The recommendation β€” facts Γ— rules β†’ a cited advisory

Now the intelligence. This is where AI advises β€” and note who does it: a skill, not the engine. air-quality-advisory reads the air-quality surface and the governed rules, and produces a cited recommendation.

Its contract

component_id: ds:airawat:skill/air-quality-advisory
kind: skill
applies_to_facts: [air.quality]
rules_domain:     air.quality
reasoning: hybrid                    # a deterministic core + an LLM for the wording
reads:  [space.aoi, governance.rule, air.quality]
writes: [governance.recommendation]
scopes:
  - { verb: read,  scope: governance.rule }
  - { verb: read,  scope: air.quality }
  - { verb: write, scope: governance.recommendation }

How it reasons (and where the line is)

The reasoning is hybrid, and deliberately so:

Two guardrails make this trustworthy β€” and they're the "AI advises, a person decides" boundary in code:

The write pins the evidence and the citation, so the advisory can be reconstructed:

rid = f"ds:{T}:governance/recommendation/air-quality-{obs[:13]}"
rec = {"schema": "governance.recommendation", "id": rid,
       "title": f"{stage} air quality β€” {binding} action",
       "summary": narrative,                       # the LLM wording, grounded in the fired rule
       "proposed_action": governing["proposed_action"],
       "rationale": f"Deterministic evaluation of {len(rules)} approved rules against {n} air.quality cells; "
                    f"governing rule '{governing['rule_id']}' fired …",
       "evidence": evidence,                        # the cells + rule it used
       "method": "rule", "produced_by": f"component://{CID}@{VER}",
       "citation": {"rule": governing["id"], "authority": governing.get("authority")}}
_call("/v1/write", {"scope": "governance.recommendation", "id": rid, "object": rec, ...})

A governance.recommendation is an input to a decision β€” never the decision itself. See Intelligence & AI and Governance & Decisions.


Step 4 Β· Into the Inbox β€” where a person decides

A recommendation sitting in the registry helps no one. The decision-router engine turns it into a task in the right official's Inbox. It's deliberately domain-blind β€” it routes any governance.recommendation; who receives it is configured per install.

Its contract

component_id: ds:airawat:engine/decision-router
version: 0.4.0
config: [DECIDER]                                   # e.g. role://<tenant>/urban.pollution-control-officer
reads:  [governance.recommendation, platform.task]
writes: [platform.case, platform.task, platform.inbox_item]

For each actionable recommendation it writes three governed facts β€” the same case β†’ task β†’ inbox item structure behind every process on the platform (see Work: Inbox, Cases & Tasks):

# 1) a CASE β€” one running instance of the "decide" process
_call("/v1/write", {"scope": "platform.case", "id": case_id, "object": {
    "schema": "platform.case", "id": case_id, "title": rec["title"],
    "status": "open", "current_step": "decide"}})

# 2) a TASK β€” the unit of work, assigned to the DECIDER, with the choices
_call("/v1/write", {"scope": "platform.task", "id": task_id, "object": {
    "schema": "platform.task", "id": task_id, "case": case_id, "verb": "decide",
    "title": f"Decide: {rec['title']}", "assignee": DECIDER,
    "options": [rec["id"]], "actions": ["approve", "reject"], "status": "open"}})

# 3) an INBOX ITEM β€” what surfaces in that person's Inbox (actor = DECIDER scopes it to them)
_call("/v1/write", {"scope": "platform.inbox_item", "id": inbox_id, "object": {
    "schema": "platform.inbox_item", "id": inbox_id, "actor": DECIDER, "kind": "decision",
    "title": rec["title"], "body": rec["summary"], "ref": task_id,
    "handler": "decide", "actions": ["approve", "reject"], "status": "unread"}})

Three things worth noticing:


Step 5 Β· The apps β€” see it, and act on it

Two apps close the loop, and neither holds any authority of its own β€” both act as the signed-in user (see Components):


Step 6 Β· Compose and install

You built the members; now bundle them. In Studio, Compose the project into the solution manifest (the members: list from the top of this tutorial), then Install it into a tenant from the Marketplace. Installing fans out β€” every member is set up, wired by its declarations, and pointed at that tenant's area of interest.

Then it runs on its own:

driver ingests readings  β†’  engine derives the surface  β†’  skill recommends
        β†’  router routes to the officer's Inbox  β†’  the officer decides  β†’  receipt

Open air-explorer to watch the map fill in, and Tasks to see (and act on) the advisory.


What you just built

That's a complete public-interest loop β€” sense β†’ derive β†’ recommend β†’ decide β€” built from small, certified, traceable parts.

Where to go next