Build a module

A module is a customer-owned extension: one or more Lambdas, a manifest, and the Terraform that deploys and registers them. Modules add compute — and optionally UI — to StrataBI without touching core. You build them in your own repo and deploy them into your own account.

Two families of module

"Module" covers a few different things, so it helps to split them into two families up front:

module_type in the manifest selects which one you're building. This guide follows a working single_lambda example end to end, then covers the app types.

Structure

An executable module:

text
my_module/
  module.json                        manifest (conforms to module.schema.json)
  src/<function_id>/handler.py        the Lambda code
  src/<function_id>/requirements.txt  dependencies (empty = stdlib + boto3)
  terraform/main.tf                   packages, deploys, and registers the module
  terraform/variables.tf
  terraform/terraform.tfvars.example
  llm_context.txt                     agent manual for this module (see below)
  README.md

An app module adds a pages/ folder — one dashboard JSON per page — that the runtime serves as a micro-app:

text
my_app_module/
  module.json                        manifest: module_type app / multi_page_app
  pages/
    overview.json                    a dashboard layout (same shape as a standalone dashboard)
    detail.json                      another page
  src/<function_id>/handler.py        lambdas the pages call (optional but typical)
  terraform/...
  llm_context.txt
  README.md

Each file in pages/ is a normal dashboard JSON (a name plus a layout of tiles). The manifest's pages[] declares page order, labels, ids, and navigation; the pages/ folder is where you keep each page's layout while you author it. See UI modules below.

1. The manifest — module.json

The manifest declares the module. It validates against the module schema.

json
{
  "$schema": "https://shaleio.com/schemas/stratabi/module.schema.json",
  "schema_version": "1.0",
  "module_id": "sales_demo",
  "module_type": "single_lambda",
  "label": "Sales Demo",
  "description": "Returns a per-category sales summary as a dataframe artifact.",
  "version": "0.1.0",
  "min_tier": "supported",
  "docs_url": "https://example.com/docs/sales-demo",
  "service_dependencies": [
    { "service_type": "lambda", "name": "sales_demo.sales_summary", "required": true }
  ],
  "lambdas": [
    {
      "function_id": "sales_summary",
      "handler": "handler.handler",
      "runtime": "python3.12",
      "memory_mb": 256,
      "timeout_seconds": 30,
      "trigger": "dashboard_event"
    }
  ]
}
FieldNotes
module_idStable id. Must match the S3 discovery prefix (see step 3).
module_typesingle_lambda, app, multi_page_app, or microservices.
min_tierOptional. The maintenance tier the module is offered on: supported, premier, or managed. Shown as a badge in the app catalog, and used as the catalog min_tier when the module is published.
docs_urlOptional. An https:// page documenting the module. The app catalog links to it, and AI tools reading module.json can use it to find the right docs without crawling a site.
service_dependenciesThe AWS services the module needs (required by the schema).
lambdas[]One entry per function: function_id, handler, runtime, memory_mb, timeout_seconds, environment, trigger.
Note
single_lambda modules are executable only — they ship no dashboard UI. UI-bearing module types (app, multi_page_app) carry a pages[] array of dashboard layouts; see the module schema for those fields.

Module types

module_type selects the shape of the module. Every type declares lambdas[]; the UI types add pages[]. These requirements come straight from the module schema:

TypeLambdasPagesWhat it is
single_lambdaexactly 1—One function a dashboard calls by lambda_index 0.
microservices1 or more—A bundle of functions, called by index.
app1 or moreexactly 1A self-contained single-page dashboard shipped with the module.
multi_page_app1 or more1 or moreMultiple pages plus a navigation_type.

Executable modules — single_lambda, microservices

No UI. Dashboards reference each function by module_id + lambda_index (the order of entries in lambdas[]). microservices is simply single_lambda with more than one function:

json
{
  "module_type": "microservices",
  "lambdas": [
    { "function_id": "summary",  "handler": "handler.handler",  "runtime": "python3.12" },
    { "function_id": "forecast", "handler": "handler.forecast", "runtime": "python3.12" }
  ]
}

UI modules — app, multi_page_app

An app module is a micro-app deployed inside the StrataBI runtime. It bundles one or more dashboard pages with the module; core serves them at the module's own route and renders each page exactly like a standalone dashboard. The difference from a normal dashboard is only where it lives: inside the module, versioned and deployed with it, able to call the module's own lambdas by lambda_index.

Pages live as dashboard JSON in a pages/ folder. Keep one file per page — pages/overview.json, pages/detail.json — each a normal dashboard layout (a name plus a layout of tiles). This is the authoring convention: the layouts are ordinary dashboards you can validate against dashboard.schema.json.

The manifest then declares the page set — id, label, order, and navigation. Each manifest page carries the same page_id / label / layout the schema defines (the layout is the tile array from the matching pages/<page_id>.json):

json
{
  "module_type": "multi_page_app",
  "navigation_type": "tabs",
  "lambdas": [ { "function_id": "metrics", "handler": "handler.handler", "runtime": "python3.12" } ],
  "pages": [
    { "page_id": "overview", "label": "Overview", "layout": [] },
    { "page_id": "detail",   "label": "Detail",   "layout": [] }
  ]
}

A tile on one of those pages references the module's own function the usual way — "exec": { "type": "lambda", "module_id": "<this module>", "lambda_index": 0 } — so an app module is fully self-contained: its pages and the compute they call ship and deploy together.

Note
The sample in this guide is a single_lambda module — the simplest, fully working type. The app / multi_page_app manifest fields are defined by the module schema; the pages/ folder is the authoring convention for the page layouts. How core lays out navigation between pages is owned by the runtime.

2. The function — handler.py

A module Lambda implements the StrataBI async tile contract. Core invokes it with a status_context that tells it exactly where to write and how to report:

python
def handler(event, context=None):
    ctx = event["status_context"]
    bucket = ctx["system_bucket"]
    key = ctx["result_s3_key"]
    result_format = ctx["result_format"]      # parquet | csv | json | ...

    try:
        df = build_dataframe(event.get("inputs") or {})
        write_dataframe(bucket, key, df, result_format)
        notify_status_writer(event.get("status_writer_lambda_arn"),
                             {**ctx, "status": "SUCCEEDED",
                              "rows": len(df), "columns": list(df.columns)})
    except Exception as e:
        notify_status_writer(event.get("status_writer_lambda_arn"),
                             {**ctx, "status": "FAILED", "message": str(e)})
        raise

The rules:

Permissions every tile Lambda needs. The function's role must be able to write the result and report status. Without these the tile never leaves Processing…:

ActionResource
s3:PutObjectarn:aws:s3:::<system-bucket>/runtime/results/*
lambda:InvokeFunctionthe core status writer, <name_prefix>-status-writer

Sync tiles. If a tile sets "sync": true, core calls the Lambda and waits for the answer instead of polling. The event then has "invocation": "sync" and status_writer_lambda_arn is null: write the artifact and return the status dict, with no status-writer call needed. The same handler serves both modes if it skips the status-writer call when the ARN is empty.

See Modules for the full contract and the two starter templates.

3. Deploy and register — Terraform

The module's Terraform does four things:

  1. Packages src/<function_id>/ into a Lambda zip and creates the function (named <name_prefix>-<module_id>-<function_id>).
  2. Creates a least-privilege execution role: invoke the status-writer Lambda, write CloudWatch logs, and s3:PutObject only under the runtime result prefix.
  3. Uploads module.json to S3 for discovery: s3://<system_bucket>/modules/<module_id>/module.json.
  4. Writes a registry item to the stratabi_module_registry DynamoDB table (hash key module_id) containing lambda_arns, lambda_function_names, module_s3_uri, a copy of the manifest, and timestamps.

Steps 3 and 4 are what make the module discoverable: at runtime core resolves module_id + lambda_index through the registry to a concrete Lambda ARN.

Required Terraform inputs:

VariableDescription
aws_region, aws_account_idTarget account.
stratabi_system_bucketExisting StrataBI system bucket.
module_registry_tableRegistry table (default stratabi_module_registry).
module_prefixS3 discovery prefix (default modules).
runtime_result_prefixWhere the Lambda may write results (default runtime/results).
status_writer_lambda_arnStatus-writer Lambda the module invokes.
name_prefixResource name prefix (default stratabi).
Caution
module_id must equal the S3 child prefix: modules/<module_id>/module.json. If they disagree, core won't discover the module.

4. llm_context.txt — the module's agent manual

Alongside module.json, ship an llm_context.txt: a short, plain-text manual that tells an agent how to use this specific module from a dashboard — its id, each function's inputs and outputs, and an example tile.

text
# StrataBI module: sales_demo
- module_id: sales_demo
- version: 0.1.0
- summary: per-category sales summary as a dataframe artifact

## Functions
- lambda_index 0 (sales_summary)
  inputs:  year (optional integer) — filter to one year
  output:  dataframe columns category (string), total_value (number)
  formats: parquet | csv | json
  blocks:  table, plotly (bar by category)

## Reference from a tile
{ "id": "sales", "exec": { "type": "lambda", "module_id": "sales_demo",
  "lambda_index": 0 }, "block": { "type": "table", "config": {} } }

Keep it concise and accurate — it is the contract an agent reads before wiring your module into a dashboard. Upload it to S3 next to module.json (modules/<module_id>/llm_context.txt) so it travels with the module.

5. Reference it from a dashboard

Once deployed and registered, any dashboard can call the module:

json
{
  "id": "sales_by_category",
  "exec": { "type": "lambda", "module_id": "sales_demo", "lambda_index": 0 },
  "block": { "type": "table", "config": {} }
}

Drive a function input (like year) from an input tile — see Inputs & parameters.

6. Declare install variables (optional)

If your module needs configuration at install time, ship an install.schema.json next to the module (or an install_schema block in module.json). StrataCTL reads it and prompts for those values generically — no module-specific code in the CLI:

json
{
  "title": "Sales demo module",
  "fields": [
    { "key": "region_filter", "prompt": "Sales region to include",
      "tfvar": "region_filter", "type": "string", "default": "all" },
    { "key": "refresh_minutes", "prompt": "Refresh interval (minutes)",
      "tfvar": "refresh_minutes", "type": "int", "default": 60 }
  ]
}

Each field supports key, prompt, tfvar, type (string/bool/int/list/enum), default, required, choices, validator, and when. Users then run stratactl init module <id>, which merges the answers into strata.yaml and generates <id>.auto.tfvars.

7. Deploy

bash
stratactl init module <id>     # collect + generate the module's tfvars (if it has any)
stratactl install <id>         # or run terraform/tofu directly in the module's dir

Then add a lambda tile referencing module_id to a dashboard and run it.

Session dashboard loading (dynamic module pages)

Added in schema 1.1.0. A multi_page_app page can render a per-session, owner-isolated dashboard in place of its static layout — useful for conversational builders, per-session scratch dashboards, previews, and development workflows. Declare a dashboard_source on the page:

json
{
  "page_id": "workspace",
  "label": "Workspace",
  "dashboard_source": {
    "s3_key": "mymodule/sessions/{runtime_session_id}/{session_id}/current.json",
    "session_input": "session_id"
  },
  "layout": [ { "id": "empty", "position": {"row":0,"order":0,"width":12},
    "block": { "type": "markdown", "config": { "content": "Nothing generated yet." } } } ]
}

At render time StrataBI reads that one dashboard JSON from your system bucket and uses its layout, rendered by the normal dashboard engine (async tiles, inputs, RBAC all apply). The page's own layout is the empty-state fallback shown when nothing is there yet.

Owner isolation is enforced by core, not your module. {runtime_session_id} is filled from the core-managed runtime session (never client input), and {session_id} comes from the input store and is sanitized, so a caller can only ever address keys under their own {runtime_session_id} prefix. The template must contain {runtime_session_id}, or the seam is ignored. Your module writes the session's dashboard to that same key; nothing lands in the global dashboard namespace.

Standard install inputs (auto-derived)

A module's Terraform runs in the StrataCI runner inside the customer account. Three values every module needs are derived automatically — the operator doesn't supply them — so a clean install is just stratactl install <module>:

InputHow it's resolvedOverride
aws_regionthe provider's region (from the runner's AWS_REGION); data.aws_regionset aws_region
aws_account_iddata.aws_caller_identity.current.account_idset aws_account_id
stratabi_system_bucketthe platform naming convention ${name_prefix}-system-${account_id} (the StrataBI system bucket)set stratabi_system_bucket

Each is declared as an optional variable defaulting to ""; the module resolves it through a local:

hcl
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

locals {
  resolved_region        = var.aws_region        != "" ? var.aws_region        : data.aws_region.current.name
  resolved_account       = var.aws_account_id     != "" ? var.aws_account_id     : data.aws_caller_identity.current.account_id
  resolved_system_bucket = var.stratabi_system_bucket != "" ? var.stratabi_system_bucket : "${var.name_prefix}-system-${data.aws_caller_identity.current.account_id}"
}

provider "aws" {
  region = var.aws_region != "" ? var.aws_region : null   # null => provider uses AWS_REGION
}

Only override when the deployment is non-standard — e.g. the platform was installed with a custom name_prefix whose system bucket doesn't follow the convention. In that case set name_prefix (preferred, so the convention still works) or stratabi_system_bucket directly, via strata init module <id> (which writes strata.yaml) or a tfvars file the runner picks up at s3://<state-bucket>/config/<module_id>.auto.tfvars.json.

The system bucket name is also published on the running app's ECS task definition as STRATABI_SYSTEM_BUCKET, so a module may alternatively discover it from there (the same data "aws_ecs_container_definition" lookup used for STRATABI_THEME).