Skip to content

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”.

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. A service token. Security → Service tokens: role user, the team’s groups, the allowlist of datasources the bot may use, requireApproval off (the portal and the bot decide per request). The value dbp_… is shown once; it goes into the bot’s secrets.
  2. Reviewers. On each datasource, approverRoles naming a named role (Security → Roles) whose members include user: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: 2 asks for two reviewers.
  3. 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_SECRET with Interactivity pointing at https://<portal>/api/slack/interactions. See OPERATOR_GUIDE.md.
  4. Datasource ids. The ids on Admin → Datasources (for example orders-prod), or the MCP endpoint’s list_datasources for the ones the token may open.
POST https://<portal>/api/v1/executions
Authorization: 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 as subject; 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 with requireTicket.
  • 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 in CALLBACK_ALLOWED_HOSTS.
  • approvedBy: optional, see the next section. The people who already approved the request where the bot lives; needs a token created with trustedApprovals.

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:

  1. 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.
  2. The bot sends approvedBy with 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" }
]
}
  • reviewer is free text, as onBehalfOf is: the identity the bot can vouch for (an email, a chat user id). 1 to 10 entries; at an 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 as approved_by, apart from reviewer (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 writeApproval is approved by policy and runs at once (202 approved with jobId, then the outcome in the thread).
  • What it does not do. A guardrail still holds the request (any DELETE, an UPDATE without WHERE or with an always-true one, DROP, TRUNCATE, or a privilege change → 202 pending with guardrail), and so do review: { reason } and a token with requireApproval. Those say the statement is suspect; no count of approvers elsewhere answers that. Do not plan around approvedBy bypassing a guardrail: it will not, and the request goes to the reviewers’ page as before.
  • Refusals: 400 when the token was not created with trustedApprovals, and for an empty list, more than 10 entries, an entry without reviewer, an at that does not parse, or an entry naming onBehalfOf (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.
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" }
]
}
  • rowCount at 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/fields come from the last statement that projected any — the result a person asked for when a script ends in a SELECT.
  • executedCount is 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.

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.

  • Direct execution against the database, and the credentials that made it possible.
  • The “may it run” logic the portal already has: guardrails (any DELETE, an UPDATE without WHERE, 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 as review.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.
  1. SELECT 1 on a datasource in the allowlist → 200.
  2. UPDATE … WHERE id = 1 on a datasource with writeApproval → 202 pending; the announcement in the thread and in the reviewers’ channel; press Approve → the outcome in the thread.
  3. DELETE FROM t → 202 pending with guardrail — with or without a WHERE.
  4. Any read with review: { reason: "test" } → 202 pending; the “held by requester” badge on Admin → Approvals.
  5. A datasource outside the allowlist → 403.
  6. GET /api/v1/executions/{id} on each → the record with its outcome.
  7. With trustedApprovals on the token: the UPDATE of step 2 with approvedBy → 202 approved with jobId, no announcement to the reviewers, the outcome in the thread; approved_by on the audit line. The DELETE of step 3 with the same approvedBy → still 202 pending with guardrail. Without the flag, approvedBy → 400.