Agents over App Data
You can give the people who use your app a chat agent that creates, reads, updates and deletes their App Data from a conversation. The agent is DGI, started as the person who is talking. It can only do what that person could do, and it never touches the tables you set aside.
This page shows how to set that up and how each control fits. It assumes you already declare your tables and expose compositions to end users.
What decides what the agent can do
An agent picks its table from the conversation, so it cannot be pinned the way a composition is. Several independent controls apply instead, and every one of them must allow an operation for it to run:
| Control | Set by | Answers |
|---|---|---|
| Exposure | identity.app.expose-virtual-workflow | Can this user start the agent at all? |
| Ownership | the table's ownership.kind | Which rows: the user's own, the whole app's, or the active workspace's? |
| Role | workspace capabilities {namespace}:{verb} | May this user's role use this module, and with which verb? |
| Module flag | policy.feature-flag.apply | Is this module on for this app? |
| Write policy | the table's declared access_mode | May end users create, update or delete here at all? |
| Data scope | the agent's profile | Which databases and tables may this agent use? |
| Reserved tables | the table's end_user_scope | Is this table off-limits to every agent? |
The agent can never do more than the user could do directly. The data scope and reserved tables only ever remove access; they never grant it.
Step 0: check your existing exposures
An exposed composition whose database_slug, table_slug or operations come from the caller lets any user reach any table, whether or not they go through the agent. New exposures like that are refused. Check for older ones:
// identity.app.exposure-audit (omit identity_app_uuid to check every app)
{ "identity_app_uuid": "<your app>" }
Each finding names the composition and the free input (unscoped_inputs: ["operations"], for example). Remove it with identity.app.unexpose-virtual-workflow, or replace it with one composition per table whose selectors are static. The goal is finding_count: 0.
Step 1: reserve the tables the agent must never touch
Some tables are plumbing: a chat relay, an outbox, idempotency keys, identity bindings. Mark them reserved in their ownership.metadata:
- slug: conversation_messages
ownership:
kind: owner
metadata:
end_user_scope: { version: 1, reserved: true }
- No agent reaches a reserved table through a wildcard. An agent that declares no scope gets "every table except the reserved ones".
- An agent reaches a reserved table only if its profile names that exact database and table.
- Your own compositions that name the table (
table_slugstatic) keep working. Reserving a table does not break your app's own features that use it. structure.applyvalidates the declaration. Valid onowner,appandorganizationtables.
Step 2: decide who may write your app-wide tables
An app table is shared by every user of the app: a catalog, a list of companies, a set of seasons. By default any user can add rows to it and edit the rows they added. To change that, put the table in declared mode and give each write verb a rule:
- slug: companies
ownership:
kind: app
metadata:
access_mode: { version: 1, declared: true }
access_policies:
- { operation: create, kind: app_admin_only }
- { operation: update, kind: app_admin_only }
- { operation: delete, kind: deny }
| Kind | Who may write | Rows |
|---|---|---|
deny | No end user | — |
app_admin_only | Users whose role holds data:<table>:write | Every row |
app_private | Any user of the app | Every row |
owner_forced / owner_only | Any user of the app | Their own rows (the default behaviour) |
- Reads stay open. In declared mode, every user of the app can still read every row.
denyandapp_admin_onlyare refused onreadandquery. - Your own writes are not affected. Rows you publish from the organization side (
appdata.publish-as-app,appdata.upsert-as-app) are not end-user writes. Neither are writes by Staff actors granted on the table. - Grant the capability to the role that edits. For
app_admin_only, adddata:companies:writeto the group withidentity.end-user.group.define. - Instances enforce it too. On an app with its own Postgres instance, the database policies enforce the same rule, so the Data API cannot get around it.
- Tables without
access_modebehave exactly as before.
Step 3: put module data behind roles
Product data that belongs to a customer workspace (a farm, a clinic, a company) is an organization table with a capability_namespace per module. A user's role in their active workspace decides each verb, and a module flag can switch the module off per app. See Ownership & workspaces.
The agent obeys this unchanged, because it runs as the user. If a role only holds harvest:read, the agent can read harvests for that user and cannot change them.
Step 4: create the agent's profile
One profile per app, saved by an organization admin with dgi.chat.profile.save:
{
"name": "Farm assistant",
"identity_app_uuid": "<your app>",
"allowed_workflow_types": [
"data.appdata.record.query",
"data.appdata.record.read",
"data.appdata.record.write",
"data.appdata.record.update",
"data.appdata.record.delete"
],
"decision_engine": "llm",
"llm_fallback": true,
"locale": "pt-BR",
"prompt": "You help farm staff keep their records. Tables: farm.planting (plantings), farm.harvest (harvests) ..."
}
identity_app_uuidbinds the profile to one app. A user of any other app is refused withprofile_not_for_app. Without it, any app in your organization can use the profile.allowed_workflow_typeslimits which workflows the agent may start. Leave out the verbs you never want the agent to use, such asrecord.delete.data_scope(optional) narrows the tables further. Omitted, the agent may use every table except the reserved ones. See Data scope.promptis where you describe your tables and their meaning. The model picks tables from this description and the conversation.default_inputsonly pre-fill values, and the model can override them. Do not use them to limit tables; usedata_scope.
Step 5: expose the agent
End users only start exposed compositions. Expose two per app.
The chat turn, with the profile fixed:
{
"name": "Farm assistant: chat",
"inputs": [{ "name": "message", "type": "STRING", "required": true }],
"layers": [{ "name": "turn", "steps": [{
"name": "turn",
"workflow_type": "dgi.chat.turn",
"input_mapping": {
"profile_uuid": { "source": "static", "value": "<profile uuid>" },
"message": { "source": "input", "field": "message" }
}
}]}]
}
To continue a conversation, also map conversation_uuid from input.
The answer to a card. Every write the agent proposes comes back as a confirmation card. The user confirms through this composition:
{
"name": "Farm assistant: respond",
"inputs": [
{ "name": "conversation_uuid", "type": "STRING", "required": true },
{ "name": "ui_id", "type": "STRING", "required": true },
{ "name": "action", "type": "STRING", "required": true }
],
"layers": [{ "name": "respond", "steps": [{
"name": "respond",
"workflow_type": "dgi.chat.respond",
"input_mapping": {
"conversation_uuid": { "source": "input", "field": "conversation_uuid" },
"ui_id": { "source": "input", "field": "ui_id" },
"action": { "source": "input", "field": "action" }
}
}]}]
}
Save each one with composition.save and expose it with identity.app.expose-virtual-workflow. Your front end starts virtual.<uuid>@<version> with the user's Sign in with Orkestia token. To render the cards, see structured chat.
Step 6: verify
Sign in as a test user of the app (a dev-mode app with your test address in dev_allowed_emails works) and check:
- A table the role allows. Ask for a record in it and confirm the card. The record is created.
- A reserved table. Ask the agent to write to it and confirm. The run is refused with
outside this run's data scope. - An app-wide table in declared mode with
deny. Write to it through a composition. The run is refused withnot open to end users. - A table the role does not cover. Ask the agent to write to it. The run is refused with
appdata_workspace_capability_denied.
Data scope
The scope is a list of the databases, tables and verbs the agent may use:
{
"version": 1,
"entries": [
{ "database_slug": "farm", "table_slug": "*", "verbs": ["read", "query"] },
{ "database_slug": "farm", "table_slug": "planting", "verbs": ["create", "read", "query", "update"] }
]
}
- Verbs are
create,read,query,updateanddelete.record.writecounts ascreate;record.updateasupdate. *matches every database or every table, except reserved tables.- Reserved tables need an entry that names the exact database and table.
- An empty
entrieslist denies every table. - It only narrows. It never grants what ownership, roles or write policy refuse.
- It also applies to batches. For
transaction.apply, every operation in the batch must be in scope, otherwise the whole batch is refused and nothing is written. - Validation. The profile is validated when you save it. A malformed scope is refused, and if one is ever stored, it denies everything rather than allowing everything.
Errors you may see
| Code or message | Meaning | What to do |
|---|---|---|
appdata_scope_denied, "outside this run's data scope" | The agent tried a table or verb outside its scope, or a reserved table | Expected for reserved tables. Otherwise widen the profile's data_scope |
appdata_scope_invalid | The scope the run carries is malformed | Re-save the profile; contact support if it persists |
appdata_policy_denied, "not open to end users" | The table's declared write kind is deny | Write it from the organization side, or change the kind |
appdata_policy_denied, "requires data:<table>:write" | Declared app_admin_only, and the user's role lacks the capability | Grant the capability to the role |
appdata_workspace_capability_denied | The role lacks {namespace}:{verb} on a module table | Grant it, or this is the intended refusal |
appdata_module_flag_off | The module is switched off for this app | Turn the flag on |
profile_not_for_app | The profile is bound to a different app | Use that app's profile |
NOT_EXPOSED_TO_END_USERS | The user started something that is not exposed | Expose a composition that wraps it |
appdata_structure_invalid (on apply) | A malformed end_user_scope or access_mode, or deny / app_admin_only on a read verb | Fix the declaration |
Hints
- Reserve plumbing first, then open the rest. No scope on the profile plus reserved plumbing tables is the simplest setup. Add a
data_scopeonly when an agent should see less than the whole app. - One agent per app. Bind every profile to its app. A profile without
identity_app_uuidcan be used by any app in your organization. - Writes always ask. The agent proposes and the user confirms. Make sure your client renders the confirm card and calls the respond composition.
- The prompt is the map. List your tables and what they hold in the profile's
prompt. The model reaches only what the scope and the user's role allow, but it chooses better when it knows the names. - Your compositions are your code. A composition that names a reserved table can use it. That is what keeps your own chat or sync features working, so review those compositions as you would any backend code.
- Reference data from your systems belongs in
apptables written withappdata.upsert-as-app, with end-user writes set todeny. - A user's direct access (their token on the Data API, or a pinned composition) follows ownership, roles and write policy. The data scope is the extra limit for agents.
Related
Ownership & workspaces · Expose · Declare · DGI · DGI quickstart · Structured chat · Chat API (option D) · Sign in with Orkestia
