Orkestia
Blog
App Data

Agents over App Data

Give your app's users an AI agent that reads and writes App Data by role, and keep chosen tables out of its reach

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:

ControlSet byAnswers
Exposureidentity.app.expose-virtual-workflowCan this user start the agent at all?
Ownershipthe table's ownership.kindWhich rows: the user's own, the whole app's, or the active workspace's?
Roleworkspace capabilities {namespace}:{verb}May this user's role use this module, and with which verb?
Module flagpolicy.feature-flag.applyIs this module on for this app?
Write policythe table's declared access_modeMay end users create, update or delete here at all?
Data scopethe agent's profileWhich databases and tables may this agent use?
Reserved tablesthe table's end_user_scopeIs 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.

The protections below cover the agent's path. A composition that still takes a free table selector is a second door to the same tables, so close it first.

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_slug static) keep working. Reserving a table does not break your app's own features that use it.
  • structure.apply validates the declaration. Valid on owner, app and organization tables.

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 }
KindWho may writeRows
denyNo end user—
app_admin_onlyUsers whose role holds data:<table>:writeEvery row
app_privateAny user of the appEvery row
owner_forced / owner_onlyAny user of the appTheir own rows (the default behaviour)
  • Reads stay open. In declared mode, every user of the app can still read every row. deny and app_admin_only are refused on read and query.
  • 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, add data:companies:write to the group with identity.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_mode behave 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_uuid binds the profile to one app. A user of any other app is refused with profile_not_for_app. Without it, any app in your organization can use the profile.
  • allowed_workflow_types limits which workflows the agent may start. Leave out the verbs you never want the agent to use, such as record.delete.
  • data_scope (optional) narrows the tables further. Omitted, the agent may use every table except the reserved ones. See Data scope.
  • prompt is where you describe your tables and their meaning. The model picks tables from this description and the conversation.
  • default_inputs only pre-fill values, and the model can override them. Do not use them to limit tables; use data_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:

  1. A table the role allows. Ask for a record in it and confirm the card. The record is created.
  2. A reserved table. Ask the agent to write to it and confirm. The run is refused with outside this run's data scope.
  3. An app-wide table in declared mode with deny. Write to it through a composition. The run is refused with not open to end users.
  4. 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, update and delete. record.write counts as create; record.update as update.
  • * matches every database or every table, except reserved tables.
  • Reserved tables need an entry that names the exact database and table.
  • An empty entries list 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 messageMeaningWhat to do
appdata_scope_denied, "outside this run's data scope"The agent tried a table or verb outside its scope, or a reserved tableExpected for reserved tables. Otherwise widen the profile's data_scope
appdata_scope_invalidThe scope the run carries is malformedRe-save the profile; contact support if it persists
appdata_policy_denied, "not open to end users"The table's declared write kind is denyWrite 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 capabilityGrant the capability to the role
appdata_workspace_capability_deniedThe role lacks {namespace}:{verb} on a module tableGrant it, or this is the intended refusal
appdata_module_flag_offThe module is switched off for this appTurn the flag on
profile_not_for_appThe profile is bound to a different appUse that app's profile
NOT_EXPOSED_TO_END_USERSThe user started something that is not exposedExpose 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 verbFix 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_scope only 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_uuid can 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 app tables written with appdata.upsert-as-app, with end-user writes set to deny.
  • 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.

Ownership & workspaces · Expose · Declare · DGI · DGI quickstart · Structured chat · Chat API (option D) · Sign in with Orkestia