Integrating a chat bot with dbportal
A guide for the team that maintains a chat bot (Slack, or any program) that receives requests to run statements on a database. It covers what to set up in the portal once, what the bot sends, what it gets back, and what the bot stops doing because the portal does it. The design behind each piece is in CONTEXT.md §4.10 (the queue), §4.15 (guardrails), §4.24 (Slack buttons), §4.25 (signed callback), §4.56 (object rules), §4.57 (the bot’s own hold) and §4.58 (the approvals the bot collected); the request and response shapes are in API_DOCS.md under “Executions API”.
What moves from the bot to the portal
Section titled “What moves from the bot to the portal”| The bot did | With the portal |
|---|---|
| Judged the statement and decided whether it may run | Sends everything; the portal decides: a read runs, a write on a datasource that requires approval, a guardrail, or a request the bot holds itself, waits |
| Asked the owners for approval | The portal announces the request to the reviewers’ channel and into the request’s thread, with Approve and Reject buttons |
| Ran the statement after approval | The portal runs the exact text that was approved, on the server, and posts the outcome into the thread |
| Called a person when something looked wrong | Sends the request with review: { reason }; it waits in the same queue, on the web page and in the thread |
| Held database credentials | None: the bot knows a datasourceId and a portal token |
The bot becomes transport: it receives the request in the chat, calls one endpoint, and optionally tells the thread what happened.
1. Set up once, in the portal
Section titled “1. Set up once, in the portal”- A service token. Security → Service tokens: role
user, the team’s groups, the allowlist of datasources the bot may use,requireApprovaloff (the portal and the bot decide per request). The valuedbp_…is shown once; it goes into the bot’s secrets. - Reviewers. On each datasource,
approverRolesnaming a named role (Security → Roles) whose members includeuser:slack:<user id>for everyone who may press the buttons, beside the groups that decide on the web page. The person a request was made for cannot decide it.approvalsRequired: 2asks for two reviewers. - The Slack app, when the bot lives in Slack:
SLACK_BOT_TOKEN(chat:write),SLACK_APPROVALS_CHANNEL(the channel the app was invited to) and, for the buttons,SLACK_SIGNING_SECRETwith Interactivity pointing athttps://<portal>/api/slack/interactions. See OPERATOR_GUIDE.md. - Datasource ids. The ids on Admin → Datasources (for example
orders-prod), or the MCP endpoint’slist_datasourcesfor the ones the token may open.
2. The request
Section titled “2. The request”POST https://<portal>/api/v1/executionsAuthorization: Bearer dbp_…Content-Type: application/json{ "datasourceId": "orders-prod", "statement": "UPDATE orders SET status = 'cancelled' WHERE id = 42", "onBehalfOf": "U0123", "reply": { "channel": "C0456", "threadTs": "1726.0001" }, "ticket": "INC-1234", "review": { "reason": "update on a billing table" }}onBehalfOf(required): the person who asked, as the chat identifies them. Written to the audit line assubject; that person cannot approve the request.reply: the channel and thread of the request. The announcement with its buttons and the outcome (ten rows at most) are posted there.ticket: optional; required by a datasource withrequireTicket.review: optional. Use it where the bot’s own analysis would have called a person: the request waits for a reviewer whatever the datasource’s policy would have let run, and the reason is shown on the reviewers’ page, in the announcement and in the record.callback: { url }: optional; the outcome is POSTed there, signed, instead of polled. The host must be inCALLBACK_ALLOWED_HOSTS.approvedBy: optional, see the next section. The people who already approved the request where the bot lives; needs a token created withtrustedApprovals.
2a. When the bot already collected the approvals
Section titled “2a. When the bot already collected the approvals”Some teams keep an approval flow in the chat itself: the request is posted, named people
react, a rule says how many are needed, and only then does the bot act. Sent as in section
2, such a write on a datasource with writeApproval would wait for a second approval on the
portal’s page. To hand the portal the first one instead:
- An administrator ticks Trusted approvals on the bot’s service token (Security → Service tokens). This is the operator’s statement that the bot’s approval flow was reviewed; it is off by default and shown on the token’s row.
- The bot sends
approvedBywith the request:
{ "datasourceId": "orders-prod", "statement": "UPDATE orders SET status = 'cancelled' WHERE id = 42", "onBehalfOf": "U0123", "reply": { "channel": "C0456", "threadTs": "1726.0001" }, "approvedBy": [ { "reviewer": "ana@example.test", "at": "2026-09-22T14:03:00Z" }, { "reviewer": "U0456", "at": "2026-09-22T14:05:12Z" } ]}revieweris free text, asonBehalfOfis: the identity the bot can vouch for (an email, a chat user id). 1 to 10 entries;atan ISO 8601 instant.- The portal does not count the approvers. How many a change needs, and who counts, is
the bot’s rule, agreed with the team that runs it. The portal records who was declared:
on the record as
approvedBy, on the audit line asapproved_by, apart fromreviewer(a decision taken on the portal’s page) so the trail says which of the two let it run. - What it does: a write on a datasource with
writeApprovalis approved by policy and runs at once (202 approvedwithjobId, then the outcome in the thread). - What it does not do. A guardrail still holds the request (any
DELETE, anUPDATEwithoutWHEREor with an always-true one,DROP,TRUNCATE, or a privilege change →202 pendingwithguardrail), and so doreview: { reason }and a token withrequireApproval. Those say the statement is suspect; no count of approvers elsewhere answers that. Do not plan aroundapprovedBybypassing a guardrail: it will not, and the request goes to the reviewers’ page as before. - Refusals:
400when the token was not created withtrustedApprovals, and for an empty list, more than 10 entries, an entry withoutreviewer, anatthat does not parse, or an entry namingonBehalfOf(the person a request is for cannot approve it; the message names the entry). - When a guardrail still holds the request, the reviewers’ page and the Slack announcement show the declared approvers, so the reviewer sees what the bot already collected. The portal refuses rather than ignores the field, so a bot that believes it declared approvers is never left with a queued write and no idea why.
3. The answers
Section titled “3. The answers”| Status | Meaning | What the bot does |
|---|---|---|
200 { execution } |
Ran at once (a read, nothing to approve). execution.execution carries rowCount, fields, rows (bounded and masked) |
Nothing required; the portal already posted the outcome into the thread |
202, execution.status: "pending" |
Waiting for a reviewer; guardrail or review says why |
Nothing; the buttons are already in the thread. Optionally: “waiting for approval” |
202, execution.status: "approved" with jobId |
Approved, a worker is running it | Poll GET /api/v1/executions/{id} until execution is set |
400 |
Invalid body: review without a reason, onBehalfOf missing, statement over 32 000 characters, a callback host not allowed, approvedBy malformed or from a token without trustedApprovals |
Show the message |
403 |
The token may not use the datasource, a write on a read-only one, a ticket required, a freeze window, or an object the datasource’s rules keep from the token | Show the portal’s message in the thread; it says what to do |
404 |
Unknown datasourceId |
Show it |
429 |
The datasource’s concurrency limit | Retry later |
Polling:
GET https://<portal>/api/v1/executions/{id}Authorization: Bearer dbp_…The record comes back; status becomes approved or rejected, and execution.status
becomes done, or failed with error as a closed word (permission_denied,
freeze_window, execution_failed, …). The portal posts the outcome into the thread on its
own; the bot reads it back only if it wants to act on it.
A request carrying more than one statement
Section titled “A request carrying more than one statement”statement may hold a script. The portal splits it under the engine’s grammar and judges
each statement: a guardrail on any of them holds the whole request, and the request is
held before anything runs, so a script that waits had none of its statements executed.
The read-only rule, the ticket rule and the freeze window read every statement too. A script
writes if any of its statements does: SELECT 1; DELETE FROM t is a write, however it
starts. The MCP run_query applies the same reading, so a script led by a SELECT cannot
carry a write through the read-only surface.
Transaction control in the body (BEGIN, START TRANSACTION, COMMIT, ROLLBACK,
SAVEPOINT, RELEASE, END) is refused whole with 400: each statement takes its own
pooled connection, so a BEGIN here would open a transaction the COMMIT on another
connection never closes.
When it runs, the statements run in order and stop at the first error. There is no transaction around them: what already ran stays. The outcome then carries two extra fields, present only when the request held more than one statement:
"execution": { "status": "failed", "rowCount": 5, "executedCount": 1, "statements": [ { "index": 0, "status": "done", "rowCount": 5, "durationMs": 3 }, { "index": 1, "status": "failed", "durationMs": 1, "error": "query_error" } ]}rowCountat the top is the sum across the statements that ran, so a reader that knows nothing of scripts still sees the whole of what changed.rows/fieldscome from the last statement that projected any — the result a person asked for when a script ends in aSELECT.executedCountis how far it got. A bot should say so:status: "failed"alone reads as “nothing happened”, and that reading is what sends someone to run the script again and apply the first statements twice.
A request with a single statement is unchanged: no statements, no executedCount.
4. A handler, in outline
Section titled “4. A handler, in outline”def handle_request(user, channel, thread_ts, datasource, sql, reason=None, ticket=None, approvers=()): body = { "datasourceId": datasource, "statement": sql, "onBehalfOf": user, "reply": {"channel": channel, "threadTs": thread_ts}, } if ticket: body["ticket"] = ticket if reason: # where the bot used to call a person body["review"] = {"reason": reason} if approvers: # the thread already approved it; needs trustedApprovals on the token body["approvedBy"] = [{"reviewer": who, "at": when} for who, when in approvers]
r = requests.post(f"{PORTAL}/api/v1/executions", json=body, headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30) data = r.json()
if r.status_code == 200: return None # the portal posted the result into the thread if r.status_code == 202: if data["execution"]["status"] == "pending": return "Waiting for approval; the buttons are in this thread." return f"Approved; running (id {data['execution']['id']})." return f"Refused by the portal: {data.get('error')}"No database credential, no list of reviewers, no statement executed by the bot.
5. What to remove from the bot
Section titled “5. What to remove from the bot”- Direct execution against the database, and the credentials that made it possible.
- The “may it run” logic the portal already has: guardrails (any
DELETE, anUPDATEwithoutWHERE,DROP,TRUNCATE, a privilege change), read-only datasources, freeze windows, tickets, object rules and row limits. Keep only what the portal cannot know, and send it asreview.reason. - The approval flow: the buttons replace it, and who may decide is declared per datasource.
A team that keeps its own flow in the chat instead sends its result as
approvedBy(section 2a); the guardrails stay the portal’s either way.
6. A test plan
Section titled “6. A test plan”SELECT 1on a datasource in the allowlist →200.UPDATE … WHERE id = 1on a datasource withwriteApproval→202 pending; the announcement in the thread and in the reviewers’ channel; press Approve → the outcome in the thread.DELETE FROM t→202 pendingwithguardrail— with or without aWHERE.- Any read with
review: { reason: "test" }→202 pending; the “held by requester” badge on Admin → Approvals. - A datasource outside the allowlist →
403. GET /api/v1/executions/{id}on each → the record with its outcome.- With
trustedApprovalson the token: theUPDATEof step 2 withapprovedBy→202 approvedwithjobId, no announcement to the reviewers, the outcome in the thread;approved_byon the audit line. TheDELETEof step 3 with the sameapprovedBy→ still202 pendingwithguardrail. Without the flag,approvedBy→400.