#Canonical Example — the Tasks domain
This is the full worked example. It threads the concept guides together on one tiny, generic domain so you can see how a data model, a workflow, an app spec, and cross-tenant sharing fit into a single build. Each step links to the guide that covers it in depth; the snippets here carry only the concrete Tasks requests unique to the example.
Everything is a live API call against https://api.helm.bridge-labs.com,
authenticated with a Personal Access Token over HTTP Basic auth
(base64(client_id:client_secret)).
The domain is intentionally minimal:
Project ──< Task >── Assignee
#1. Data model
Three entity types, each with a JSON attribute schema and a stable identifier key:
Project—{ key, name, status }, identified bykey.Task—{ key, title, state, priority }, identified bykey, withstateandpriorityindexed as filterable and sortable.Assignee—{ email, name }, identified byemail.
Two relationship rules connect them: ProjectToTask (one-to-many) and
TaskToAssignee (many-to-one). The flow is always schema → entity type →
relationship rule → entities → links — see
Entities & Data Model for the full mechanics.
Register the Task attribute schema first:
POST/schema/schemasRegister the Task schemaAPI docs ↗Try it
Only key is required. A merge upsert validates the attributes it sends, and
the reusable job in step 2 sends just key and state — a required title
would fail that job.
Then bind it to an entity type, declaring the key attribute as its stable
identifier key (what a bulk upsert matches an existing row on) and which
attributes are indexed:
POST/entity/entity-typesCreate the Task entity typeAPI docs ↗Try it
Repeat for Project (schema project_attributes, identifier key) and
Assignee (schema assignee_attributes, identifier email), then create the
two relationship rules. A rule names its types directly; max_to_cardinality: 1
lets each Task belong to at most one Project:
POST/entity/relationship-rulesCreate the ProjectToTask ruleAPI docs ↗Try it
TaskToAssignee is the same shape from Task to Assignee with
"max_from_cardinality": 1, so each task has at most one assignee.
#2. Workflow — an inline action and a referenced job
The workflow onboard-project runs two job steps:
- Inline job — an
upsertEntityaction that creates a "Kickoff"Taskfor the new project, authored directly on the workflow step. - Referenced job — invokes the reusable
complete-taskjob (defined once under/definition/jobs), which marks that taskdone.
Inline keeps step-local logic next to the workflow; a referenced job is reusable
across workflows, and which job the reference runs is a
binding each environment can decide for itself. Smart
values thread the trigger payload through both steps. See
Workflows and Smart Values
— a step's output binds as outputs.<stepId>.
First the reusable job:
POST/definition/jobsDefine the complete-task jobAPI docs ↗Try it
Then the workflow that creates the kickoff task inline and calls that job:
POST/definition/workflowsDefine the onboard-project workflowAPI docs ↗Try it
#3. Activation & monitoring
A definition is draft until it's activated for your tenant — activation
merges env, reconciles the workflow's triggers, and can bind an access policy
for its runs (policy_id, or an inline policy). It's idempotent. The response
lists each trigger with its webhook_url. See
Triggers.
POST/workflows/{id}/activateActivate onboard-projectAPI docs ↗Try it
Start a run by naming the workflow and its trigger; input becomes
context.input for the jobs. It answers 202 with the new run (its id is the
run id). A webhook caller would instead POST to the trigger's webhook_url.
POST/workflows/wrRun onboard-projectAPI docs ↗Try it
Each invocation produces a run you can inspect. List runs filtered by workflow:
GET/workflows/runs/workflowsList runs for a workflowAPI docs ↗Try it
Pass workflow_id as a query parameter, then drill into a single run for its
per-job and per-action detail via GET /workflows/runs/workflows/{id}: its
job_runs carry each job's status, error and an action_log. The run ends
success — the ACME-kickoff task then reads "state": "done" — or error,
with the failing action and its reason in error.
#4. App spec — a dashboard with live widgets
The app spec (the spec_document body on the wire) defines one dashboard,
tasks, with a board page composed of three widgets:
- a headline that renders from config alone,
- a data grid of tasks, bound by name to a
$queryentry in the page'sdataSourcesmap. On an entity source a column'sfieldis the bare attribute name (key, notattributes.key), and the grid pages, sorts and filters on the server — see Data Grids, - an actions widget exposing an "Add task" command: an inline
actionsstep runningupsertEntity(the same action catalog a workflow job uses), then anotifytoast and arefresh.
The spec also carries a sedna provider, whose identity block reads the
caller from the render request, and a member policy that every user of the app
acts under (defaultPolicy). Without a policy granting read on Task, the
grid renders empty and the render carries an ERROR notification
Data for "tasks" couldn't be loaded: Entity type "Task" not found.
POST/definition/specsCreate the Tasks app specAPI docs ↗Try it
The full widget set — light widgets that render from config and heavy widgets that need authored sample data — is documented, with live previews, in the Widget Catalogue. Here is the headline rendered live:
{
"widgetName": "headline",
"config": {
"text": "Tasks",
"headerLevel": 1
}
}
See App spec structure for
every spec_document part.
#5. App activation (config, integration, token)
Activating the spec for a tenant creates the activation chain (see
Apps (Bridge)). Each call below uses the spec's
id from step 4 as {specId}.
Activate the spec. This provisions the spec's
memberpolicy into your tenant:PUT
/apps/{specId}/configActivate the Tasks appAPI docs ↗Try itRegister the provider organisation the app renders for —
provideris the spec's provider key, andexternal_idis what the spec'scompany_external_idextractor reads (company.identifier):POST
/access/external-companiesRegister the provider organisationAPI docs ↗Try itWire an integration to that organisation, using the
idthe previous call returned (GET /access/external-companieslists it again):POST
/apps/{specId}/integrationsCreate the integrationAPI docs ↗Try itMint the run token for the integration. The response carries
raw_token(besidedata) exactly once — capture it:PUT
/apps/{specId}/integrations/{integrationId}/tokenMint the app tokenAPI docs ↗Try it
Render the app as the provider would, with the app token and the payload the
spec's identity block reads:
curl -X POST https://api.helm.bridge-labs.com/apps/run/<integration_id> \
-H "Authorization: Bearer helm_at_<your_token>" \
-H "Content-Type: application/json" \
-d '{
"user": { "id": "u-1", "emailAddress": "jane@acme.example", "firstName": "Jane" },
"company": { "identifier": "acme" }
}'
The 200 carries surfaces, state and referenceData. The grid's rows — the
tasks the workflow created, highest priority first — come back under
referenceData, keyed by the grid's element id
(:p/board/task_table/data_grid:). To press "Add task", send the same payload
plus the state from that response and "action": ":p/board/task_actions/0:";
the answer is a full re-render with an INFO notification Task added and the
new task among the grid's rows. See
Apps → Interactions.
#6. User provisioning
Because the provider is configured to auto-create users (autoCreateUsers), the
first render for a new provider user provisions the tenant user from the spec's
identity block — see
Apps → Providers & identity.
GET /access/external-users lists the provisioned users with their
auth_user_id. To put one user under a specific spec policy, assign it by key
(PUT /apps/{specId}/users/{authUserId}/policy with { "policy": "member" }).
See Provisioning.
#7. Shares & grants
To let a principal read Tasks within your tenant, create an intra-tenant
grant (tier-2). grantee_id is a policy's id (POST /access/policies
creates one), and for entity rows resource_id is the Task entity type's
id:
POST/access/grantsGrant read on Task rowsAPI docs ↗Try it
To let another tenant build on this — activate the shared spec, or store their
own Tasks of the shared type — the owner lists the resources in a manifest and
offers it; the other tenant accepts, which creates read-only shares
(tier-1), and activates the spec in its own tenant. The manifest must list the
Task entity type as well as the tasks_app spec, because the spec's policy
names it. Cross-tenant data reads need both a share and a grant. See
Share with Another Tenant,
Accept & Activate, and
Shares & Grants.
#8. The same read over GraphQL and in a workflow
The grid's data comes from an engine-native $query source over Task —
the same filter/sort/include vocabulary as POST /entity/query (see
Entities & Data Model → Read & query).
The same data is also reachable over GraphQL (POST /entity/graphql; root
fields are PascalCase, operators are underscore-prefixed, and filterable
attributes nest under attributes:). Prototype the read in the playground at
/graphql/:
POST/entity/graphqlOpen tasks, highest priority firstAPI docs ↗Try it
orderBy accepts only attributes indexed with sortable: true, which is why
step 1 marks priority sortable. The same read as a workflow step is a
queryEntity action — the root field becomes typeName, each where condition
a filter on ["attributes", "<name>"], and orderBy the sort:
{
"stepId": "open-tasks",
"name": "queryEntity",
"config": {
"typeName": "Task",
"filter": {
"operator": "eq",
"path": ["attributes", "state"],
"value": "open"
},
"sort": [{ "path": ["attributes", "priority"], "direction": "desc" }],
"limit": 10
}
}
Both return the same tasks in the same order; in the workflow the rows land in
outputs.open-tasks.data. See
Querying & GraphQL.