# Agents
Source: https://docs.powabase.ai/api-reference/agents
Create AI agents, assign tools and knowledge bases, configure MCP servers and hooks, and execute conversations.
Each agent wraps an LLM with a system prompt, tools, knowledge bases, and optional MCP servers. Agents use a ReAct loop to reason about user messages, call tools as needed, and generate streaming responses. Sessions maintain conversation history across turns.
## Common Patterns
Create an agent, assign tools and knowledge bases, then use the streaming endpoint for conversations. Pass session\_id to continue multi-turn conversations. For human-in-the-loop workflows, configure hooks and use the approve endpoint when approval\_requested events are received.
## Agent CRUD
### GET /api/agents
List all agents.
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/agents", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/agents
Create a new agent.
```json Request theme={null}
{
"name": "My Agent",
"model": "gpt-4o",
"system_prompt": "You are a helpful assistant.",
"settings": { "temperature": 0.7 }
}
```
The body honors `name` (required), `model`, `system_prompt`, and `settings` (object). Top-level fields like `temperature` are silently dropped; anything model-tuning related must be nested in `settings`. `model` is a LiteLLM model ID passed through unchanged (e.g. `gpt-4o`, `claude-sonnet-4-6`, `openrouter//`); see [Bring your own LLM](/guides/byollm) for the per-provider format and key requirements.
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/agents", headers=headers, json={"name": "My Agent", "model": "gpt-4o", "system_prompt": "You are helpful.", "settings": {"temperature": 0.7}})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents`, { method: "POST", headers, body: JSON.stringify({ name: "My Agent", model: "gpt-4o", system_prompt: "You are helpful.", settings: { temperature: 0.7 } }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"name": "My Agent", "model": "gpt-4o"}'
```
### GET /api/agents/
Get an agent by ID.
Agent ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/agents/{agent_id}", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents/${agentId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PATCH /api/agents/
Update an agent's name, model, system prompt, or settings.
Agent ID
```python Python theme={null}
response = requests.patch(f"{BASE_URL}/api/agents/{agent_id}", headers=headers, json={"settings": {"temperature": 0.5}})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}`, { method: "PATCH", headers, body: JSON.stringify({ settings: { temperature: 0.5 } }) });
```
```bash cURL theme={null}
curl -X PATCH '{BASE_URL}/api/agents/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"settings": {"temperature": 0.5}}'
```
### DELETE /api/agents/
Delete an agent.
Agent ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/agents/{agent_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/agents/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Tool Assignments
### POST /api/agents//tools
Assign a tool to the agent.
Agent ID
```json Request theme={null}
{ "tool_name": "database_query" }
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/agents/{agent_id}/tools", headers=headers, json={"tool_name": "database_query"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/tools`, { method: "POST", headers, body: JSON.stringify({ tool_name: "database_query" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{id}/tools' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"tool_name": "database_query"}'
```
### GET /api/agents//tools
List tool assignments for the agent.
Agent ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/agents/{agent_id}/tools", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents/${agentId}/tools`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents/{id}/tools' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PATCH /api/agents//tools/
Update a tool assignment's `config_override`.
Agent ID
Assignment ID
The full `config_override` object. Tool-specific shape; for database tools include `schemas: { : [, ...] }`.
The body MUST use the key `config_override`. Passing `config` (or any other top-level key) is a silent no-op: the route returns 200 with the unchanged assignment because the handler only inspects `config_override`.
**How `config_override` works per tool type:**
* **Database tools (`database_query`, `database_write`)**: `config_override.schemas: { : [, ...] }` controls which tables the agent can read/write. Schemas must be a dict, schema names must match `^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`, system schemas (`ai`, `auth`, `storage`, `pg_*`, etc.) are rejected, and each value must be a list of valid table names.
* **All other builtin tools (`web_search`, `web_scrape`, `http_request`, `code_execute`, `storage_read`, `storage_write`)**: any key in `config_override` whose name matches a parameter in the tool's `input_schema` is **force-injected into every call's arguments**. Keys not in the schema are silently dropped (no error, no warning). For example, setting `config_override: { "max_results": 3, "include_domains": ["example.com"] }` on a `web_search` assignment locks every search the agent does to 3 results from `example.com`.
This constrains a tool without changing what the agent is told about it: the LLM still sees the full schema, but its requested values for those keys are overridden.
* **Custom tools**: `config_override` is not used. Custom-tool behavior is controlled via the `Tool` row's own `config` (endpoint, method, headers) at create time.
* **MCP tools**: `config_override` is not used. MCP-tool behavior is controlled via the `AgentMcpServer` row's `headers` and `enabled` flag.
```json Request theme={null}
{
"config_override": {
"schemas": {
"public": ["users", "orders"]
}
}
}
```
```python Python theme={null}
response = requests.patch(f"{BASE_URL}/api/agents/{agent_id}/tools/{assignment_id}", headers=headers, json={"config_override": {"schemas": {"public": ["users", "orders"]}}})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/tools/${assignmentId}`, { method: "PATCH", headers, body: JSON.stringify({ config_override: { schemas: { public: ["users", "orders"] } } }) });
```
```bash cURL theme={null}
curl -X PATCH '{BASE_URL}/api/agents/{id}/tools/{assignment_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"config_override": {"schemas": {"public": ["users", "orders"]}}}'
```
### DELETE /api/agents//tools/
Remove a tool assignment from the agent.
Agent ID
Assignment ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/agents/{agent_id}/tools/{assignment_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/tools/${assignmentId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/agents/{id}/tools/{assignment_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Knowledge Base Assignments
### POST /api/agents//knowledge-bases
Link a knowledge base to the agent. Creates a dynamic search tool.
Agent ID
```json Request theme={null}
{ "knowledge_base_id": "kb-uuid" }
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/agents/{agent_id}/knowledge-bases", headers=headers, json={"knowledge_base_id": kb_id})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/knowledge-bases`, { method: "POST", headers, body: JSON.stringify({ knowledge_base_id: kbId }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{id}/knowledge-bases' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"knowledge_base_id": "kb-uuid"}'
```
### GET /api/agents//knowledge-bases
List knowledge base assignments for the agent.
Agent ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/agents/{agent_id}/knowledge-bases", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents/${agentId}/knowledge-bases`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents/{id}/knowledge-bases' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### DELETE /api/agents//knowledge-bases/
Remove a knowledge base from the agent.
Agent ID
Assignment ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/agents/{agent_id}/knowledge-bases/{assignment_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/knowledge-bases/${assignmentId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/agents/{id}/knowledge-bases/{assignment_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## MCP Servers
### POST /api/agents//mcp-servers
Add an MCP server to the agent.
Agent ID
```json Request theme={null}
{
"url": "https://mcp.example.com",
"transport": "http",
"name": "My MCP"
}
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/agents/{agent_id}/mcp-servers", headers=headers, json={"url": "https://mcp.example.com", "transport": "http", "name": "My MCP"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/mcp-servers`, { method: "POST", headers, body: JSON.stringify({ url: "https://mcp.example.com", transport: "http", name: "My MCP" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{id}/mcp-servers' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"url": "https://mcp.example.com", "transport": "http"}'
```
### GET /api/agents//mcp-servers
List MCP servers for the agent.
Agent ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/agents/{agent_id}/mcp-servers", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents/${agentId}/mcp-servers`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents/{id}/mcp-servers' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PUT /api/agents//mcp-servers/
Update an MCP server configuration.
Agent ID
MCP Server ID
```python Python theme={null}
response = requests.put(f"{BASE_URL}/api/agents/{agent_id}/mcp-servers/{server_id}", headers=headers, json={"url": "https://new-url.com"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/mcp-servers/${serverId}`, { method: "PUT", headers, body: JSON.stringify({ url: "https://new-url.com" }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/agents/{id}/mcp-servers/{server_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"url": "https://new-url.com"}'
```
### DELETE /api/agents//mcp-servers/
Remove an MCP server from the agent.
Agent ID
MCP Server ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/agents/{agent_id}/mcp-servers/{server_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/mcp-servers/${serverId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/agents/{id}/mcp-servers/{server_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Hooks
Intercept agent execution at lifecycle boundaries. For the full event/type semantics, the webhook request/response contract, and the streaming caveat, see [Hooks & Middleware](/concepts/agents-tools#hooks--middleware).
### POST /api/agents//hooks
Add a hook to the agent.
Agent ID
When the hook fires. One of: `OnRunStart`, `PreToolUse`, `OnDelegation`, `PostToolUse`, `PreResponse`, `OnRunComplete`. Stored verbatim and **not** validated; an unrecognized value is saved but never fires.
What the hook does. One of: `http` (POST to a webhook URL), `rule` (local condition evaluation), `approval` (human-in-the-loop gate). Also stored verbatim and not validated.
Type-specific configuration (may be `{}`). For `http`: `url` (required), optional `headers`, `timeout_seconds` (default 5). For `approval`: optional `message`, `timeout` (default 300). For `rule`: a `condition` string or a `rules` array.
Tool name to target. Omit to match all tools (or for non-tool events).
Whether the hook is active.
Execution order among the agent's hooks (ascending).
Hooks are immutable; there is no update endpoint. To change a hook, `DELETE` it and create a new one.
```python Python theme={null}
# An HTTP hook that POSTs to your webhook before every tool call
response = requests.post(f"{BASE_URL}/api/agents/{agent_id}/hooks", headers=headers, json={"event": "PreToolUse", "type": "http", "config": {"url": "https://example.com/hook"}})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/hooks`, { method: "POST", headers, body: JSON.stringify({ event: "PreToolUse", type: "http", config: { url: "https://example.com/hook" } }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{id}/hooks' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"event": "PreToolUse", "type": "http", "config": {"url": "https://example.com/hook"}}'
```
```json 201 theme={null}
{
"id": "hook_...",
"agent_id": "agent_...",
"event": "PreToolUse",
"matcher": null,
"type": "http",
"config": { "url": "https://example.com/hook" },
"enabled": true,
"position": 0,
"created_at": "2026-01-01T00:00:00Z"
}
```
### GET /api/agents//hooks
List hooks for the agent.
Agent ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/agents/{agent_id}/hooks", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents/${agentId}/hooks`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents/{id}/hooks' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### DELETE /api/agents//hooks/
Remove a hook from the agent.
Agent ID
Hook ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/agents/{agent_id}/hooks/{hook_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/hooks/${hookId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/agents/{id}/hooks/{hook_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Execution
### GET /api/agents//sessions
List chat sessions for the agent.
Agent ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/agents/{agent_id}/sessions", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents/${agentId}/sessions`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents/{id}/sessions' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/agents//run
Run agent synchronously, returning the full response as JSON. No streaming, no tools, no ReAct loop: a single LLM call against the conversation context. Useful for question-answering. If you need tool use, use `/run/stream`.
The endpoint accepts the **same context-selection surface** as `/run/stream`: you can attach ad-hoc retrieval (`knowledge_bases`), a pre-built context (`context_handler_id`), a raw context string (`context_override`), or by-reference items (`context_items`). Only **one** context source may be provided per request; sending more than one returns 400. `runtime_knowledge_bases` is **not** accepted here (it requires the tool loop) — sending it returns 400 pointing you to `/run/stream`.
Agent ID
```json Request theme={null}
{
"message": "Hello",
"session_id": "optional-session-uuid",
"knowledge_bases": [{ "id": "kb-uuid", "top_k": 5 }],
"max_context_tokens": 8000,
"citations_enabled": false
}
```
| Body field | Type | Notes |
| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------- |
| `message` | string (required) | User input |
| `session_id` | string | Reuse a session for multi-turn. Omit to start a fresh session; `session_id` is returned. |
| `knowledge_bases` | array | Ad-hoc retrieval: `[{ id, top_k?, similarity_threshold?, filter_metadata?, retrieval_method? }]` |
| `context_handler_id` | string | Use a pre-built context handler (see [Context Handlers](/api-reference/context-handlers)) |
| `context_override` | string | Raw context string injected verbatim; skips retrieval |
| `context_items` | array | By-reference items: `[{ id, ... }]` or by-value items with `text` |
| `max_context_tokens` | int | Truncate retrieved context to this token budget (default from agent settings or platform default) |
| `citations_enabled` | bool | Append a citation instruction and parse `[1]`-style refs from the response (default `false`) |
| `temperature` | float | Per-run override; falls back to agent setting |
| `response_format` | object | LiteLLM-compatible JSON-schema for structured output |
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/agents/{agent_id}/run", headers=headers, json={"message": "Hello"})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents/${agentId}/run`, { method: "POST", headers, body: JSON.stringify({ message: "Hello" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{id}/run' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"message": "Hello"}'
```
### POST /api/agents//run/stream
Run agent with streaming SSE. Supports tools, the ReAct loop, and multi-turn via session\_id. SSE event types include start, chunk, tool\_call, tool\_result, reasoning, reasoning\_summary, and complete; the reasoning events surface the model's internal thought stream when reasoning is enabled. Set reasoning\_requested=true to request reasoning output for this run.
The streaming endpoint accepts the **same context-selection surface** as `/run` above (`knowledge_bases` / `context_handler_id` / `context_override` / `context_items`, mutually exclusive), plus all the same `max_context_tokens`, `citations_enabled`, `temperature`, and `response_format` fields. See [Streaming](/concepts/streaming-patterns) for the full SSE event catalog.
```json Request theme={null}
{
"message": "Hello",
"session_id": "optional-session-uuid",
"reasoning_requested": false,
"runtime_knowledge_bases": [{ "id": "kb-uuid", "top_k": 5 }]
}
```
#### Runtime knowledge base references
`runtime_knowledge_bases` gives the agent a real `knowledge_search` tool over knowledge bases you name **for this one request** — agentic search (the model decides when and what to search, and can search repeatedly), without attaching anything to the agent. Use it when a query should be grounded in a document set that isn't part of the agent's permanent configuration.
```json theme={null}
{
"message": "What does the referenced contract say about termination?",
"runtime_knowledge_bases": [
{ "id": "kb-uuid", "top_k": 5, "source_ids": ["source-uuid"] }
]
}
```
| Entry field | Type | Notes |
| ---------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | string (required) | An existing knowledge base in the project |
| `top_k` | int | 1–100. Per-run override; falls back to the KB's `retrieval_config`, then the project default |
| `retrieval_method` | string | `vector_search`, `full_text`, `hybrid`, or `tree_search` |
| `similarity_threshold` | number | 0–1 |
| `filter_metadata` | object | Metadata filter applied at retrieval |
| `source_ids` | array | Restrict retrieval to these sources — each must be indexed in **that entry's** knowledge base |
| `max_context_tokens` | int | **Per-entry knob** — distinct from the top-level `max_context_tokens` request field (which caps injected context and is always honored). 1000–128000; honored only when the run's search tool resolves to exactly one KB (attached + runtime combined) |
Behavior to know:
* **Strictly per-request.** Nothing is persisted — follow-up messages in the same session must re-send the field.
* **Merges with attached KBs** into the run's single `knowledge_search` tool. An entry naming a KB that is also attached **overrides that attachment's config for the run** (e.g. tighten `top_k` or scope `source_ids` for one query).
* **Validated before the stream opens.** Unknown ids, out-of-range knobs, unknown entry keys (typos like `top_K` are rejected, not ignored), more than **10** entries, or duplicate ids all return `400` — no billing, no stream.
* **Combinable** with the context fields above (it adds a tool, not injected context). Combined with the preload `knowledge_bases` field, the preload retrieval runs up front, plus each `knowledge_search` call the model makes — both billed, no dedup.
* **Streaming only.** The non-streaming `/run` has no tool loop and rejects the field with `400`.
* **Authorization note:** any caller authorized to run the agent can reference any KB in the project via this field — this matches the project-wide access posture of the `ai` schema and is not restricted per-agent. Expose the endpoint from trusted backends only.
Agent ID
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json={"message": "Hello", "reasoning_requested": True},
stream=True,
)
for line in response.iter_lines():
if line and line.decode().startswith("data: "):
event = json.loads(line.decode()[6:])
print(event)
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST", headers,
body: JSON.stringify({ message: "Hello", reasoning_requested: true }),
});
// Parse SSE events: start, chunk, tool_call, tool_result, reasoning, complete
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/agents/{id}/run/stream' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"message": "Hello", "reasoning_requested": true}'
```
### GET /api/agents/runs/
Fetch a single agent run by `run_id`, independent of session context. Returns: `id`, `run_id`, `session_id`, `parent_orchestration_run_id`, `parent_workflow_execution_id`, `status`, `input_messages`, `output_messages`, `content`, `usage`, `retrieved_context`, `error`, `started_at`, `completed_at`, `steps`, `events`, `tool_calls`, `reasoning_steps`, and `created_at`. The parent fields are `null` for top-level runs; `session_id` is `null` for delegated or workflow-block runs.
For runs that belong to a session, ownership is enforced: the caller must own the session (or be using a service-role key). To avoid leaking existence, ownership failures are returned as 404, not 403.
Run ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/agents/runs/{run_id}", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/agents/runs/${runId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents/runs/{run_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/agents/runs//approve
Approve or deny a pending tool call (human-in-the-loop).
Run ID
```json Request theme={null}
{ "approved": true }
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/agents/runs/{run_id}/approve", headers=headers, json={"approved": True})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/runs/${runId}/approve`, { method: "POST", headers, body: JSON.stringify({ approved: true }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/runs/{run_id}/approve' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"approved": true}'
```
## Error Responses
Agent routes return `{"error": ""}`.
| Status | Description |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | Missing or invalid required field (e.g. `name`, `tool_type`/`tool_name`, `knowledge_base_id`, MCP server `name`/`url`, hook `event`/`type`/`config`) |
| 400 | Invalid `config_override.schemas` on tool PATCH (e.g. not a dict, contains a system schema, or has invalid table names) |
| 404 | No agent, tool/KB/MCP/hook assignment, or run exists with the given ID |
| 404 | `/run` or `/run/stream`: the supplied `session_id` is not owned by the caller (returned as 404 to avoid leaking existence) |
| 409 | Knowledge base already assigned to this agent |
| 409 | An MCP server with this name already exists for this agent |
# AI Provider Keys
Source: https://docs.powabase.ai/api-reference/ai-provider-keys
Store, validate, and rotate per-project credentials for OpenAI, Anthropic, Google, and OpenRouter.
Each project stores its own set of model-provider API keys (OpenAI, Anthropic, Google, OpenRouter). Keys are encrypted at rest and decrypted only when an agent, workflow, or indexing job needs them. Stored keys are returned masked; the full secret is never echoed back.
When you set a key, the platform calls the provider to validate it. Soft failures (provider down, transient network issue) still store the key with `is_valid: false`; hard failures (provider rejects the credential) reject the request without storing anything.
## Model-string format
A stored key is selected by the **provider prefix** of the `model` string you pass to an agent, indexing job, or other consumer. Powabase routes models through LiteLLM, so these are LiteLLM model IDs.
| Provider | Format | Example |
| ------------ | -------------------------- | -------------------------------------- |
| `openai` | bare ID | `gpt-4o` |
| `anthropic` | bare ID | `claude-sonnet-4-6` |
| `google` | `gemini/` | `gemini/gemini-2.5-pro` |
| `openrouter` | `openrouter//` | `openrouter/qwen/qwen3-235b-a22b-2507` |
OpenRouter slugs must match LiteLLM's `openrouter/...` cost-map keys, which can differ from OpenRouter's own slugs. For a full walkthrough, including DeepSeek and the function-calling requirement for agents, see [Bring your own LLM](/guides/byollm).
## Common Patterns
Configure keys once after creating a project, then let agents and indexing jobs pick them up automatically. Use the batch PUT endpoint when wiring keys from a setup script. Use the `/validate` endpoint to test a key before storing it (e.g. in an admin UI).
### GET /api/ai-provider-keys
List all configured provider keys. Each entry includes `id`, `provider`, `masked_key` (the only representation of the secret the API ever returns), `is_valid`, `last_validated_at`, `created_at`, and `updated_at`.
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/ai-provider-keys", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/ai-provider-keys`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/ai-provider-keys' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/ai-provider-keys
Upsert a single provider key. Returns 201 on insert, 200 on update.
One of: `openai`, `anthropic`, `google`, `openrouter`.
The provider's raw API key. Validated against the provider before storage.
```json Request theme={null}
{ "provider": "openai", "api_key": "sk-..." }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/ai-provider-keys", headers=headers, json={"provider": "openai", "api_key": "sk-..."})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/ai-provider-keys`, { method: "POST", headers, body: JSON.stringify({ provider: "openai", api_key: "sk-..." }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/ai-provider-keys' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"provider": "openai", "api_key": "sk-..."}'
```
### PUT /api/ai-provider-keys
Batch upsert. Pass any subset of providers. Null or empty values are no-ops (won't clear existing keys; use DELETE for that). On any hard-fail validation, the entire batch is rolled back.
```json Request theme={null}
{
"openai": "sk-...",
"anthropic": "sk-ant-...",
"google": null,
"openrouter": ""
}
```
```python Python theme={null}
requests.put(f"{BASE_URL}/api/ai-provider-keys", headers=headers, json={"openai": "sk-...", "anthropic": "sk-ant-..."})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/ai-provider-keys`, { method: "PUT", headers, body: JSON.stringify({ openai: "sk-...", anthropic: "sk-ant-..." }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/ai-provider-keys' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"openai": "sk-...", "anthropic": "sk-ant-..."}'
```
### DELETE /api/ai-provider-keys/
Remove a stored key. Returns 204 with no body.
One of: `openai`, `anthropic`, `google`, `openrouter`.
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/ai-provider-keys/openai", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/ai-provider-keys/openai`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/ai-provider-keys/openai' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/ai-provider-keys/validate
Validate a key against the provider without storing it. Returns `{ "is_valid": true }` on success, or `{ "is_valid": false, "error": "..." }` with the provider's rejection reason.
One of: `openai`, `anthropic`, `google`, `openrouter`.
The key to test.
```json Request theme={null}
{ "provider": "openai", "api_key": "sk-..." }
```
```python Python theme={null}
result = requests.post(f"{BASE_URL}/api/ai-provider-keys/validate", headers=headers, json={"provider": "openai", "api_key": "sk-..."}).json()
if not result["is_valid"]:
print("Rejected:", result.get("error"))
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/ai-provider-keys/validate`, { method: "POST", headers, body: JSON.stringify({ provider: "openai", api_key: "sk-..." }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/ai-provider-keys/validate' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"provider": "openai", "api_key": "sk-..."}'
```
### GET /api/ai-provider-keys/platform\_supported
Returns which providers the platform itself has keys for at this pod: the "AI-on-us" providers, where the platform pays for inference and bills you in credits instead of requiring your own key.
The Studio's LLM Provider Keys settings page uses this to render "AI-on-us active" vs "BYOK required" badges per provider. It's also a pre-flight check before pointing an agent at a model whose provider you haven't BYOK'd yet: if the provider is `platform_supported`, the model works; otherwise the agent run returns `402 provider_key_decrypt_failed` until you upsert your own key.
The path uses an **underscore** (`platform_supported`), not a hyphen. Hyphenating it 404s.
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/ai-provider-keys/platform_supported", headers=headers)
print(response.json()) # {"providers": ["openai", "anthropic"]}
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/ai-provider-keys/platform_supported`, { headers });
const { providers } = await res.json();
```
```bash cURL theme={null}
curl '{BASE_URL}/api/ai-provider-keys/platform_supported' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
**Response:**
```json theme={null}
{ "providers": ["openai", "anthropic"] }
```
The list is a subset of `["openai", "anthropic", "google", "openrouter"]`: whichever ones the pod has a platform-side env-var key configured for.
## Error Responses
Errors return `{"error": ""}`; validation failures additionally include a `fields` map with the per-provider rejection reason.
| Status | Description |
| ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| 400 | `provider` is not one of the four supported values |
| 400 | Provider rejected the key (hard fail). Response body: `{"error": "Validation failed", "fields": {"": ""}}` |
| 401 | Missing or invalid auth headers |
# Auth
Source: https://docs.powabase.ai/api-reference/auth
End-user-facing GoTrue endpoints at /auth/v1/*: sign up, sign in, refresh, magic link, password recovery, OAuth, MFA, user management, and admin operations.
The Auth API is GoTrue v2.184.0 mounted at `/auth/v1/*` on your project URL. Every endpoint accepts the project's Anon Key (or, for admin endpoints, the Service Role Key) as both the `apikey` and `Authorization: Bearer` headers, except where noted (e.g., after sign-in, the user's access token replaces the Anon Key in `Authorization`).
For end-to-end signup/signin flows, see [Signup, signin, magic link](/guides/auth-signup-signin). For OAuth, see [OAuth providers](/guides/auth-oauth-providers). For the conceptual model, see [Auth model](/concepts/auth-model).
## Common headers
Two header sets you'll use depending on whether the user is signed in:
**Unauthenticated requests** (signup, signin, recovery, OAuth initiation):
```
apikey:
Authorization: Bearer
Content-Type: application/json
```
**Authenticated requests** (`/user`, `/logout`, MFA enrollment, etc.):
```
apikey:
Authorization: Bearer
Content-Type: application/json
```
**Admin requests** (`/admin/users/*`):
```
apikey:
Authorization: Bearer
Content-Type: application/json
```
## Signup
### POST /auth/v1/signup
Create a new user with email + password (or phone + password if phone auth is enabled). On default `autoConfirm: true`, returns a session immediately. On `autoConfirm: false`, returns the user record only; the user must verify their email via the link sent by GoTrue.
Email address. Either `email` or `phone` is required.
E.164 phone number (e.g., `+14155552671`). Requires `GOTRUE_EXTERNAL_PHONE_ENABLED=true` and an SMS provider configured.
At least 6 characters by GoTrue default.
Arbitrary key/value pairs to store in `user_metadata`. User-editable.
`{ captcha_token: "..." }` if CAPTCHA is enabled.
```json Email + password theme={null}
{
"email": "alice@example.com",
"password": "correcthorsebatterystaple",
"data": { "display_name": "Alice" }
}
```
```python Python theme={null}
requests.post(
f"{BASE_URL}/auth/v1/signup",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {ANON_KEY}", "Content-Type": "application/json"},
json={"email": "alice@example.com", "password": "correcthorsebatterystaple"},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/signup`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${ANON_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ email: "alice@example.com", password: "correcthorsebatterystaple" }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/auth/v1/signup' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "correcthorsebatterystaple"}'
```
**Response (autoConfirm: true):**
```json theme={null}
{
"access_token": "eyJ...",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 1748563200,
"refresh_token": "v1.MzQ1...",
"user": {
"id": "...",
"aud": "authenticated",
"role": "authenticated",
"email": "alice@example.com",
"user_metadata": {"display_name": "Alice"},
"app_metadata": {"provider": "email", "providers": ["email"]},
"created_at": "2026-05-29T00:00:00Z"
}
}
```
**Response (autoConfirm: false):** the user object only, no tokens.
## Signin
### POST /auth/v1/token?grant\_type=password
Exchange email/password (or phone/password) for an access token + refresh token.
Must be `password`.
Either `email` or `phone` is required.
E.164 format. Requires phone auth enabled.
```python Python theme={null}
requests.post(
f"{BASE_URL}/auth/v1/token",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {ANON_KEY}", "Content-Type": "application/json"},
params={"grant_type": "password"},
json={"email": "alice@example.com", "password": "..."},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${ANON_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ email: "alice@example.com", password: "..." }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/auth/v1/token?grant_type=password' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "..."}'
```
**Response:** same shape as signup with autoConfirm: true.
### POST /auth/v1/token?grant\_type=refresh\_token
Exchange a refresh token for a new access token + new refresh token. Refresh-token rotation is enabled by default: each refresh token is single-use, with a 10-second grace window for concurrent refresh attempts.
```python Python theme={null}
requests.post(
f"{BASE_URL}/auth/v1/token",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {ANON_KEY}", "Content-Type": "application/json"},
params={"grant_type": "refresh_token"},
json={"refresh_token": "v1.MzQ1..."},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${ANON_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: "v1.MzQ1..." }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/auth/v1/token?grant_type=refresh_token' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type": "application/json" \
-d '{"refresh_token": "v1.MzQ1..."}'
```
### POST /auth/v1/token?grant\_type=pkce
Exchange a PKCE authorization code (from OAuth or magic link) for a session.
Authorization code from the redirect query string.
The PKCE verifier you generated before calling `/authorize`.
See [OAuth providers](/guides/auth-oauth-providers#pkce--when-and-why) for the full PKCE walkthrough.
## Passwordless / magic link
### POST /auth/v1/otp
Send a one-time email (or SMS) with a sign-in link. The user clicks it, GoTrue redirects them back to your app with tokens in the URL fragment.
Either `email` or `phone` is required.
E.164 format. Requires phone auth enabled.
Default `true`. When false, returns 400 if no user with that address exists, useful for "magic link only for existing users."
`{ email_redirect_to: "...", data: { ... } }`. `email_redirect_to` overrides the default site URL for this request; `data` populates `user_metadata` if `create_user` results in a new user.
```json Magic link theme={null}
{
"email": "alice@example.com",
"create_user": true,
"options": { "email_redirect_to": "https://your-app.example.com/auth/callback" }
}
```
```python Python theme={null}
requests.post(
f"{BASE_URL}/auth/v1/otp",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {ANON_KEY}", "Content-Type": "application/json"},
json={"email": "alice@example.com", "create_user": True, "options": {"email_redirect_to": "..."}},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/otp`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${ANON_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
email: "alice@example.com",
create_user: true,
options: { email_redirect_to: "..." },
}),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/auth/v1/otp' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type": "application/json" \
-d '{"email": "alice@example.com", "create_user": true, "options": {"email_redirect_to": "..."}}'
```
**Response:** `200 OK` with empty body if the email was queued. Returns 200 even for non-existent addresses when `create_user: false` would have failed; GoTrue treats this as "do not enumerate users."
### POST /auth/v1/verify
Verify an OTP token directly (alternative to clicking the email link). Useful for native apps that handle the email link via a URL scheme.
`signup`, `magiclink`, `recovery`, `invite`, `email_change`, `sms`, or `phone_change`.
The token from the email link or SMS.
Required for email-typed verifications.
Required for SMS verifications.
**Response:** session tokens, same shape as `POST /token?grant_type=password`.
## Password recovery
### POST /auth/v1/recover
Send a password-reset email. The user clicks it and arrives at your `redirect_to` URL signed in (tokens in URL fragment), then calls `PUT /user` to set a new password.
`{ redirect_to: "https://your-app.example.com/auth/reset-password" }` overrides the default site URL for this request.
```python Python theme={null}
requests.post(
f"{BASE_URL}/auth/v1/recover",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {ANON_KEY}", "Content-Type": "application/json"},
json={"email": "alice@example.com", "options": {"redirect_to": "..."}},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/recover`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${ANON_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ email: "alice@example.com", options: { redirect_to: "..." } }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/auth/v1/recover' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type": "application/json" \
-d '{"email": "alice@example.com"}'
```
Always returns 200, even if the email doesn't exist, to prevent user enumeration.
## OAuth
### GET /auth/v1/authorize
Initiates an OAuth flow. Returns a 302 redirect to the upstream provider. Not callable from cURL in the normal sense; you put the user's browser at this URL.
One of: `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `kakao`, `keycloak`, `linkedin_oidc`, `notion`, `slack`, `slack_oidc`, `spotify`, `twitch`, `twitter`, `workos`, `zoom`. Must be enabled on the project.
Where to redirect after the OAuth dance completes. Must be in the project's `uriAllowList`.
Space-separated extra scopes to request from the provider.
PKCE code challenge (base64url-encoded SHA-256 of the verifier). Including this puts the flow in PKCE mode: the code comes back in the query string instead of the URL fragment.
Must be `S256` when `code_challenge` is set.
Full walkthrough at [OAuth providers](/guides/auth-oauth-providers).
### GET /auth/v1/callback
GoTrue's own OAuth callback. The upstream provider redirects to this URL after the user authorizes; GoTrue exchanges the code, finalizes the session, then 302s the browser to your `redirect_to`. You shouldn't call this directly; it's the URL you register with the provider.
## User management (authenticated)
### GET /auth/v1/user
Return the currently-signed-in user. Read from the access token's claims plus a fresh lookup against `auth.users`.
```python Python theme={null}
requests.get(
f"{BASE_URL}/auth/v1/user",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {access_token}"},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/user`, {
headers: { apikey: ANON_KEY, Authorization: `Bearer ${accessToken}` },
});
```
```bash cURL theme={null}
curl '{BASE_URL}/auth/v1/user' \
-H "apikey: " -H "Authorization: Bearer "
```
### PUT /auth/v1/user
Update the signed-in user's email, password, phone, or `user_metadata`. Cannot modify `app_metadata` from here (use the admin API).
Triggers a confirmation email if changed. The new email isn't active until confirmed; the session continues under the old email until then.
The new password.
Same flow as email; sends a verification SMS.
Merged into `user_metadata`.
```python Python theme={null}
requests.put(
f"{BASE_URL}/auth/v1/user",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
json={"data": {"display_name": "Alice Smith"}},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/user`, {
method: "PUT",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ data: { display_name: "Alice Smith" } }),
});
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/auth/v1/user' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type": "application/json" \
-d '{"data": {"display_name": "Alice Smith"}}'
```
### POST /auth/v1/logout
Invalidate the current session (revokes the refresh token server-side). Optionally specify scope.
`global` (default: revoke all refresh tokens for this user), `local` (just this session), or `others` (every session except this one).
```python Python theme={null}
requests.post(
f"{BASE_URL}/auth/v1/logout",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {access_token}"},
params={"scope": "global"},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/logout?scope=global`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${accessToken}` },
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/auth/v1/logout?scope=global' \
-H "apikey: " -H "Authorization: Bearer "
```
## Multi-factor authentication
TOTP MFA is enabled by default; phone-based MFA requires `GOTRUE_MFA_PHONE_ENROLL_ENABLED=true` and Twilio configured. Up to 10 factors per user.
### POST /auth/v1/factors
Enroll a new MFA factor. Returns a TOTP secret (and QR code URI) the user can add to their authenticator app.
`totp` or `phone`.
User-visible label, e.g., "iPhone Authy".
TOTP issuer field, e.g., your app name.
### POST /auth/v1/factors//challenge
Start a challenge (TOTP doesn't need this; phone sends the OTP).
### POST /auth/v1/factors//verify
Verify the challenge code. On first-time enrollment, also activates the factor. Returns an `aal2` (Authenticator Assurance Level 2) access token if successful.
### DELETE /auth/v1/factors/
Unenroll a factor. Requires the current access token to be at aal2 if the factor being deleted is the user's last factor, which prevents lockout from a stolen access token.
## Admin (service-role only)
These require the **Service Role Key**, not the user's access token. Server-side use only.
### GET /auth/v1/admin/users
List all users in the project. Supports pagination via `page` and `per_page` (default 50).
### POST /auth/v1/admin/users
Create a user with any email, password, metadata. Skips confirmation. Useful for migrations.
Default `true`.
Default `true`.
The only way to set `app_metadata`.
Override the default `authenticated` role.
### GET /auth/v1/admin/users/
Fetch a specific user, including the fields `GET /user` doesn't return (banned status, last sign-in, MFA factors, identities).
### PUT /auth/v1/admin/users/
Update any field on the user, including `app_metadata`, `ban_duration`, and `role`. The right place to set custom claims that policies will read via `auth.jwt()`.
`24h`, `48h`, `none`, etc. Banned users get 401 on sign-in.
### DELETE /auth/v1/admin/users/
Hard-delete a user. Cascades through identities, sessions, MFA factors, and (if configured) the `auth.users` row's downstream references in `public.*`.
### POST /auth/v1/admin/generate\_link
Generate a magic-link URL without sending an email. Useful for impersonation flows or custom email delivery.
`signup`, `invite`, `magiclink`, `recovery`, `email_change_current`, `email_change_new`.
**Response:** `{ "action_link": "...", "email_otp": "...", "hashed_token": "...", ... }`.
## Error Responses
GoTrue returns errors as `{"error": "code", "error_description": "human-readable"}` or `{"msg": "..."}` depending on the endpoint. Common codes:
| Status | Code | When |
| ------ | ---------------------------- | -------------------------------------------------------------------------- |
| 400 | `invalid_grant` | Wrong email/password, expired refresh token, or refresh-token already used |
| 400 | `email_not_confirmed` | Sign-in attempt before email verification |
| 400 | `email_address_invalid` | Malformed email |
| 400 | `weak_password` | Below 6 characters by default |
| 400 | `user_already_exists` | Signup with an existing email |
| 401 | `unauthorized` | Missing or expired access token |
| 403 | `not_admin` | Hitting `/admin/*` without the service role key |
| 422 | `invalid_credentials` | Same as 400 `invalid_grant` in some GoTrue versions |
| 429 | `over_email_send_rate_limit` | Too many email-sending attempts (30/hour by default) |
| 429 | `over_sms_send_rate_limit` | Too many SMS attempts |
| 429 | `over_request_rate_limit` | Too many failed verify attempts |
The `/admin/*` endpoints additionally return:
| Status | Code | When |
| ------ | ------------------- | ---------------------------------------------------- |
| 404 | `user_not_found` | User UUID doesn't exist |
| 422 | `validation_failed` | Body field shape is wrong (e.g., non-string `phone`) |
## Next steps
End-to-end flows in Python, TypeScript, and cURL.
Provider-specific configuration + PKCE.
JWT structure, the four roles, refresh-token rotation, the autoconfirm default.
How to use auth.uid() and auth.jwt() in policies on the tables your users will hit.
# Authentication & Storage
Source: https://docs.powabase.ai/api-reference/auth-storage
Auth user management and storage operations are served by the control-plane proxy, not the per-project service API. Routing is ref-only: /api/platform/auth/{ref}/* and /api/platform/storage/{ref}/*.
Auth and Storage endpoints are proxied through the control plane to each project's GoTrue (authentication) and Storage (file management) services. Substitute \ with your Studio app's base URL (e.g. [http://localhost:3001](http://localhost:3001) in dev) and \{ref} with your project ref. These endpoints use a different auth scheme than the /api/\* AI surface: a signed-in user's platform JWT, not the project's Service Role key from the Connect modal.
## Common Patterns
For authentication, list users with GET /api/platform/auth/\{ref}/users and create them with POST. For storage, list buckets and upload files under /api/platform/storage/\{ref}/\*. A platform JWT (signed-in user's access token) is required; service-role bypasses are not available through the proxy. For client-side GoTrue and Storage calls that talk directly to your project (not through this proxy), use the Anon (Publishable) Key from the Connect modal with RLS and Storage policies.
## Authentication (via Control Plane)
### GET /api/platform/auth//users
List project auth users. Full URL: GET \/api/platform/auth/\{ref}/users
```python Python theme={null}
import requests
PLATFORM_URL = "http://localhost:3001" # your Studio app base
REF = "your-project-ref"
response = requests.get(
f"{PLATFORM_URL}/api/platform/auth/{REF}/users",
headers={"Authorization": "Bearer YOUR_PLATFORM_JWT"},
)
```
```typescript TypeScript theme={null}
const PLATFORM_URL = "http://localhost:3001"; // your Studio app base
const ref = "your-project-ref";
const res = await fetch(
`${PLATFORM_URL}/api/platform/auth/${ref}/users`,
{ headers: { Authorization: `Bearer ${platformJwt}` } },
);
```
```bash cURL theme={null}
curl '/api/platform/auth/{ref}/users' \
-H "Authorization: Bearer YOUR_PLATFORM_JWT"
```
### POST /api/platform/auth//users
Create an auth user. Full URL: POST \/api/platform/auth/\{ref}/users
```python Python theme={null}
requests.post(
f"{PLATFORM_URL}/api/platform/auth/{REF}/users",
headers={"Authorization": "Bearer YOUR_PLATFORM_JWT", "Content-Type": "application/json"},
json={"email": "user@example.com", "password": "securepass"},
)
```
```typescript TypeScript theme={null}
await fetch(
`${PLATFORM_URL}/api/platform/auth/${ref}/users`,
{
method: "POST",
headers: { Authorization: `Bearer ${platformJwt}`, "Content-Type": "application/json" },
body: JSON.stringify({ email: "user@example.com", password: "securepass" }),
},
);
```
```bash cURL theme={null}
curl -X POST '/api/platform/auth/{ref}/users' \
-H "Authorization: Bearer YOUR_PLATFORM_JWT" -H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "securepass"}'
```
## Storage (via Control Plane)
### GET /api/platform/storage//buckets
List storage buckets. Full URL: GET \/api/platform/storage/\{ref}/buckets
```python Python theme={null}
response = requests.get(
f"{PLATFORM_URL}/api/platform/storage/{REF}/buckets",
headers={"Authorization": "Bearer YOUR_PLATFORM_JWT"},
)
```
```typescript TypeScript theme={null}
const res = await fetch(
`${PLATFORM_URL}/api/platform/storage/${ref}/buckets`,
{ headers: { Authorization: `Bearer ${platformJwt}` } },
);
```
```bash cURL theme={null}
curl '/api/platform/storage/{ref}/buckets' \
-H "Authorization: Bearer YOUR_PLATFORM_JWT"
```
### POST /api/platform/storage//object//
Upload a file. Full URL: POST \/api/platform/storage/\{ref}/object/\{bucket}/\{path}
```python Python theme={null}
with open("file.txt", "rb") as f:
requests.post(
f"{PLATFORM_URL}/api/platform/storage/{REF}/object/mybucket/file.txt",
headers={"Authorization": "Bearer YOUR_PLATFORM_JWT"},
files={"file": f},
)
```
```typescript TypeScript theme={null}
const form = new FormData();
form.append("file", blob);
await fetch(
`${PLATFORM_URL}/api/platform/storage/${ref}/object/mybucket/file.txt`,
{ method: "POST", headers: { Authorization: `Bearer ${platformJwt}` }, body: form },
);
```
```bash cURL theme={null}
curl -X POST '/api/platform/storage/{ref}/object/mybucket/file.txt' \
-H "Authorization: Bearer YOUR_PLATFORM_JWT" -F "file=@file.txt"
```
## Error Responses
| Status | Code | Description |
| ------ | -------------- | --------------------------------------------- |
| 401 | `unauthorized` | Missing or invalid authentication credentials |
# Context Handlers
Source: https://docs.powabase.ai/api-reference/context-handlers
Execute standalone knowledge retrieval outside of agent runs. Useful for building custom RAG pipelines.
Context handlers run standalone RAG retrieval without an agent. Send a query with one or more knowledge base configurations, and the handler retrieves the most relevant chunks from each knowledge base. Use this when you want your own LLM integration but still want the platform's vector search.
## Common Patterns
Execute a context handler with POST /api/context-handlers, providing a query and an array of knowledge\_bases (each specifying a knowledge\_base\_id and optional top\_k). The response includes the retrieved chunks ranked by relevance, which you can inject into your own LLM prompts.
### GET /api/context-handlers
List context handlers with pagination.
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/context-handlers", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/context-handlers`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/context-handlers' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/context-handlers
Create and execute a context handler. Retrieves relevant chunks from one or more knowledge bases.
```json Request theme={null}
{
"query": "How to get started?",
"knowledge_bases": [
{ "id": "kb-uuid", "top_k": 5 }
],
"max_context_tokens": 8000
}
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/context-handlers", headers=headers, json={
"query": "How to get started?",
"knowledge_bases": [{"id": kb_id, "top_k": 5}],
"max_context_tokens": 8000,
})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/context-handlers`, {
method: "POST", headers,
body: JSON.stringify({
query: "How to get started?",
knowledge_bases: [{ id: kbId, top_k: 5 }],
max_context_tokens: 8000,
}),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/context-handlers' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"query": "How to get started?", "knowledge_bases": [{"id": "kb-uuid", "top_k": 5}]}'
```
Request body uses `knowledge_bases`; the response payload carries it back as `knowledge_base_configs`. The response also includes a `metadata` object (with `query_enrichment`: the rewritten or expanded queries the platform actually issued) and `errors` (per-KB partial failures, if any).
### GET /api/context-handlers/
Get a context handler result by ID.
Handler ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/context-handlers/{handler_id}", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/context-handlers/${handlerId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/context-handlers/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Error Responses
Context-handler routes return `{"error": ""}`.
| Status | Description |
| ------ | ----------------------------------------------------------------------------------------- |
| 400 | `query` is required (POST), or `knowledge_bases` is required and must be non-empty (POST) |
| 404 | No context handler exists with the given ID |
| 500 | Retrieval failed during handler creation — body contains the underlying error message |
# Copilot
Source: https://docs.powabase.ai/api-reference/copilot
AI-powered workflow builder. Describe what you want in natural language and the copilot generates the workflow graph.
The Copilot API is an AI-assisted workflow builder. Create a copilot session linked to a workflow, describe what you want in natural language, and the copilot generates the workflow graph. Iterate over multiple chat turns to refine it, then save a snapshot to apply the generated graph.
## Common Patterns
Create a copilot session with a workflow\_id, send messages via the chat endpoint (streaming SSE), and save good suggestions with the snapshot endpoint. Each chat message can produce a new version of the workflow graph. The copilot maintains conversation context within a session.
### POST /api/copilot/sessions
Create a copilot session for a workflow.
```json Request theme={null}
{ "workflow_id": "wf-uuid" }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/copilot/sessions", headers=headers, json={"workflow_id": wf_id})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/copilot/sessions`, { method: "POST", headers, body: JSON.stringify({ workflow_id: wfId }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/copilot/sessions' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"workflow_id": "uuid"}'
```
### GET /api/copilot/sessions
Get session by workflow\_id.
Workflow ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/copilot/sessions?workflow_id={wf_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/copilot/sessions?workflow_id=${wfId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/copilot/sessions?workflow_id={wf_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### DELETE /api/copilot/sessions/
Delete a copilot session.
Session ID
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/copilot/sessions/{session_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/copilot/sessions/${sessionId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/copilot/sessions/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/copilot/sessions//messages
Get copilot conversation history.
Session ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/copilot/sessions/{session_id}/messages", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/copilot/sessions/${sessionId}/messages`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/copilot/sessions/{id}/messages' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/copilot/sessions//messages//snapshot
Save the copilot's workflow suggestion as a snapshot.
Session ID
Message ID
```python Python theme={null}
requests.post(f"{BASE_URL}/api/copilot/sessions/{session_id}/messages/{message_id}/snapshot", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/copilot/sessions/${sessionId}/messages/${messageId}/snapshot`, { method: "POST", headers });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/copilot/sessions/{id}/messages/{mid}/snapshot' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/copilot/sessions//chat
Send a message and stream the copilot response (SSE). The copilot runs a ReAct loop with access to a fixed set of tools for inspecting and modifying the workflow.
Session ID
```json Request theme={null}
{
"message": "Add a step that sends Slack notifications when the run finishes",
"workflow_state": { "nodes": [...], "edges": [...] }
}
```
| Body field | Type | Notes |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message` | string (required) | User message |
| `workflow_state` | object | The current editor state (nodes + edges). The copilot needs this so it can reason against the live in-progress workflow, not just the persisted version. The Studio sends it automatically. |
#### SSE event types
The chat stream emits these events (each as `data: ` lines):
| Event | Payload | When |
| --------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status` | `{ message }` | Human-readable status string ("Thinking...", "Modifying workflow\...", etc.) for the UI. Derived from the ReAct step and tool name via the platform's status map. |
| `tool_call` | `{ name, arguments }` | A copilot tool is about to execute |
| `tool_result` | `{ name, result }` | A copilot tool finished (truncated to readable size) |
| `content_delta` | `{ delta }` | A token of the copilot's text response |
| `complete` | `{ message_id, content, workflow_diff }` | Final event. `workflow_diff` is the structured change-set the copilot proposes (added/removed/modified blocks and edges); pass `message_id` to `/snapshot` to apply it. |
| `error` | `{ error }` | The copilot failed mid-run |
#### Copilot tools
The copilot has eight tools, exposed via the ReAct loop:
| Tool | Purpose |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `modify_workflow` | Add / update / remove blocks and edges in the in-progress workflow. The `workflow_diff` in the `complete` event is the cumulative effect of all `modify_workflow` calls. |
| `get_block_info` | Look up the schema for a block type (which config fields, defaults, validation) before constructing it |
| `get_db_schema` | Read the project database's schema (tables, columns, types) so the copilot can build correct SQL for code or general\_api blocks |
| `list_project_assets` | Enumerate existing agents, KBs, orchestrations, workflows the copilot might want to call |
| `get_asset_details` | Fetch one asset's full config. For agents, the system prompt is truncated to 1000 chars to fit context |
| `execute_public_sql` | Run a read-only SQL query against the project DB (public schema only) for exploring data before designing a block |
| `get_workflow_run_logs` | Read past execution logs from this workflow, e.g. for "the last run failed at step X, fix it" |
| `manage_project_asset` | Create / update / delete a project asset (agent, KB, etc.) on the copilot's recommendation. Requires user confirmation in the UI before the change persists. |
The copilot itself runs as an agent under platform billing. Its model is configurable via `/api/copilot/settings/model` and defaults to `gpt-5.2`, with options including GPT-5.2, GPT-4.1, o3/o4, and Claude Opus/Sonnet/Haiku 4.x. Only function-calling-capable models are allowed.
Runtime limits: 25 ReAct steps max, temperature 0.7, workflow state truncated at 50,000 chars total (block configs over 2,000 chars get `... [truncated]`).
```python Python theme={null}
requests.post(
f"{BASE_URL}/api/copilot/sessions/{session_id}/chat",
headers=headers,
json={"message": "Build a workflow that...", "workflow_state": current_state},
stream=True,
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/copilot/sessions/${sessionId}/chat`, {
method: "POST", headers,
body: JSON.stringify({ message: "Build a workflow that...", workflow_state: currentState }),
});
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/copilot/sessions/{id}/chat' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"message": "Build a workflow that..."}'
```
### GET /api/copilot/settings/model
Get copilot model configuration.
```python Python theme={null}
requests.get(f"{BASE_URL}/api/copilot/settings/model", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/copilot/settings/model`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/copilot/settings/model' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PUT /api/copilot/settings/model
Set copilot model.
```json Request theme={null}
{ "model": "gpt-4o" }
```
```python Python theme={null}
requests.put(f"{BASE_URL}/api/copilot/settings/model", headers=headers, json={"model": "gpt-4o"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/copilot/settings/model`, { method: "PUT", headers, body: JSON.stringify({ model: "gpt-4o" }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/copilot/settings/model' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"model": "gpt-4o"}'
```
## Error Responses
Copilot routes return `{"error": ""}`.
| Status | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------------- |
| 400 | Missing required field (`workflow_id` on POST/GET, `message` on chat, `pre_snapshot` on snapshot, `model` on settings PUT) |
| 400 | Settings PUT: `model` value fails registry validation (must be one of the allowed copilot model options) |
| 404 | No workflow or copilot session exists with the given ID |
# Database (Authenticated proxy)
Source: https://docs.powabase.ai/api-reference/database
Auth-required CRUD over your project's public schema, plus a table introspection endpoint.
The `/api/database/*` endpoints are a thin auth-required proxy over PostgREST, scoped to your project's `public` schema. They use the same operators and conventions as PostgREST under the hood, but every call requires a valid Powabase auth token (no anon-key access).
System schemas (`ai`, `auth`, `storage`, `pg_catalog`, `information_schema`, etc.) are blocked at this layer — those are managed via dedicated routes (`/api/agents`, `/api/knowledge-bases`, ...).
When you need the full PostgREST query language (filter operators, embedded relations, ordering, RPC calls), use the [PostgREST endpoints](/api-reference/postgrest) instead. Use this proxy when you want one auth model across all platform calls and don't need the full PostgREST surface.
## Common Patterns
Discover tables with `GET /tables`, then read or mutate rows by primary key. The `/openapi` endpoint returns the project's full PostgREST OpenAPI spec — useful for UIs that render an API reference for user-defined tables.
### GET /api/database/tables
List tables in the schema (only `public` is currently allowed). Returns `{ "tables": ["users", "orders", ...] }`.
Defaults to `public`. Any other value returns 400.
```python Python theme={null}
requests.get(f"{BASE_URL}/api/database/tables", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/database/tables`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/database/tables' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/database/tables/
List rows from a table via PostgREST. Supports `limit` (default 50) and `offset` query parameters.
Table name in the public schema. Must match `^[A-Za-z_][A-Za-z0-9_]*$`.
Defaults to 50.
Defaults to 0.
Defaults to `public`.
```python Python theme={null}
requests.get(f"{BASE_URL}/api/database/tables/users?limit=20", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/database/tables/users?limit=20`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/database/tables/users?limit=20' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/database/tables//
Fetch a single row by `id`. Returns the row as a JSON object (uses PostgREST's `Accept: application/vnd.pgrst.object+json`).
Table name
Row primary key
Defaults to `public`.
```python Python theme={null}
requests.get(f"{BASE_URL}/api/database/tables/users/{user_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/database/tables/users/${userId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/database/tables/users/{user_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/database/tables/
Insert a row. The body is forwarded to PostgREST with `Prefer: return=representation`, so the response contains the inserted row.
Table name
```json Request theme={null}
{ "email": "ana@acme.io", "role": "admin" }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/database/tables/users", headers=headers, json={"email": "ana@acme.io"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/database/tables/users`, { method: "POST", headers, body: JSON.stringify({ email: "ana@acme.io" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/database/tables/users' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"email": "ana@acme.io"}'
```
### PATCH /api/database/tables//
Update a row by `id`. Returns the updated row.
Table name
Row primary key
```json Request theme={null}
{ "role": "viewer" }
```
```python Python theme={null}
requests.patch(f"{BASE_URL}/api/database/tables/users/{user_id}", headers=headers, json={"role": "viewer"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/database/tables/users/${userId}`, { method: "PATCH", headers, body: JSON.stringify({ role: "viewer" }) });
```
```bash cURL theme={null}
curl -X PATCH '{BASE_URL}/api/database/tables/users/{user_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"role": "viewer"}'
```
### DELETE /api/database/tables//
Delete a row by `id`.
Table name
Row primary key
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/database/tables/users/{user_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/database/tables/users/${userId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/database/tables/users/{user_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/database/openapi
Return the project's full PostgREST OpenAPI/Swagger spec. The frontend uses this to render an API reference for user-defined tables; you can use it to drive a settings UI or to introspect the schema programmatically.
```python Python theme={null}
spec = requests.get(f"{BASE_URL}/api/database/openapi", headers=headers).json()
```
```typescript TypeScript theme={null}
const spec = await fetch(`${BASE_URL}/api/database/openapi`, { headers }).then(r => r.json());
```
```bash cURL theme={null}
curl '{BASE_URL}/api/database/openapi' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Error Responses
Errors from this proxy return `{"error": ""}`. Errors from the upstream PostgREST/Postgres are forwarded verbatim with their original status code.
| Status | Description |
| ------- | -------------------------------------------------------------------- |
| 400 | Table name failed validation (must match `^[A-Za-z_][A-Za-z0-9_]*$`) |
| 400 | Schema is not `public` (only `public` is exposed via this proxy) |
| 401 | Missing or invalid auth headers |
| 500 | `GET /tables` failed against `information_schema` |
| 502 | PostgREST connection failed (network error, gateway unreachable) |
| 4xx/5xx | Forwarded from PostgREST (e.g. constraint violation, RLS denial) |
# Extensions
Source: https://docs.powabase.ai/api-reference/extensions
The Postgres extensions preloaded in every Powabase project, the ones you can CREATE EXTENSION yourself, and where each one lives.
A Powabase project's Postgres comes with a set of extensions preloaded for the platform's own services. You can use them too, and you can install additional ones from the Postgres image's shipped library set.
For the broader concept of schemas, see [Schemas](/concepts/schemas). For pgvector specifically, see [User-managed pgvector](/guides/user-pgvector). For pg\_net via the DB-webhook pattern, see [DB webhooks](/guides/db-webhooks).
## Preloaded in every project
These extensions are available at project provision time without `CREATE EXTENSION`. Powabase's own init scripts add `vector` and `pg_net`; the others come from the upstream `supabase/postgres:15.8.1.085` image's default init.
| Extension | Schema | What it does |
| ------------------- | ----------------- | --------------------------------------------------------------------------------------- |
| `vector` (pgvector) | `public` | Vector similarity search with HNSW / IVFFlat indexes |
| `pg_net` | `extensions` | Async HTTP from inside Postgres (used by DB webhooks) |
| `pgcrypto` | `public` | Cryptographic functions (`gen_random_uuid`, `crypt`, `digest`, etc.) |
| `uuid-ossp` | `extensions` | Alternative UUID generation (`uuid_generate_v4`, etc.) |
| `pg_graphql` | preloaded library | GraphQL on top of Postgres, callable via `POST /rest/v1/rpc/graphql` |
| `vault` | preloaded library | Encrypted secret storage. Available via direct Postgres only; no platform code uses it. |
`pgcrypto`'s `gen_random_uuid()` is what your `CREATE TABLE ... id uuid DEFAULT gen_random_uuid()` columns use. No setup needed.
`pg_net` is in the `extensions` schema (not `public`) so its functions stay out of the default search path. Call them as `extensions.http_post(...)`, or `SET search_path TO extensions, public` first.
## You-can-install
The Postgres image (`supabase/postgres:15.8.1.085`) includes a long list of extensions you can install yourself with `CREATE EXTENSION`. The most useful for application code:
| Extension | Schema | What it does |
| ------------------------- | ------------ | ------------------------------------------------------------------------------- |
| `pg_trgm` | `extensions` | Trigram similarity for fuzzy text matching, useful for autocomplete |
| `unaccent` | `extensions` | Strip accents from text for diacritic-insensitive search |
| `citext` | `extensions` | Case-insensitive text type: `WHERE email = 'foo@bar.com'` matches `Foo@Bar.com` |
| `btree_gin`, `btree_gist` | `extensions` | GIN / GiST index support for scalar types alongside JSONB / tsvector |
| `postgres_fdw` | `extensions` | Query a different Postgres database as if it were a local table |
| `hstore` | `extensions` | Key-value type (less common now that JSONB exists, but still supported) |
| `tsm_system_rows` | `extensions` | `TABLESAMPLE SYSTEM_ROWS(N)` for fast random sampling |
| `intarray` | `extensions` | GIN-indexable integer-array operators |
Install them in the `extensions` schema:
```sql theme={null}
CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS citext SCHEMA extensions;
```
You'll need to be connected as `supabase_admin` (via the Database URL); `anon`, `authenticated`, and `service_role` don't have CREATE on the database.
## Not available
Some extensions you might be looking for that aren't in the Powabase Postgres image:
| Extension | Status |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `pg_cron` | Not enabled. Scheduling jobs in-database isn't supported on Powabase; use a workflow with a cron trigger instead. |
| `pg_jsonschema` | Not present. Use CHECK constraints or application-level validation. |
| `pgsodium` | Not enabled. Use `pgcrypto` for crypto, or move secrets to your application config. |
| `postgis` | Not in the standard image. If you need it, contact support. |
| `pgmq`, `pg_partman`, etc. | Various community extensions. File a platform request if you need one. |
The platform team can add extensions on request for enterprise customers.
## Listing what's available
To see what's installed in your project right now:
```sql theme={null}
SELECT extname, nspname AS schema, extversion
FROM pg_extension e
JOIN pg_namespace n ON n.oid = e.extnamespace
ORDER BY extname;
```
To see what's available to `CREATE EXTENSION`:
```sql theme={null}
SELECT name, default_version
FROM pg_available_extensions
WHERE installed_version IS NULL
ORDER BY name;
```
This shows extensions the image has shipped but you haven't installed. Run `CREATE EXTENSION foo SCHEMA extensions` to install any of them.
## What lives where
Extensions install their objects (functions, types, tables) into a schema. Powabase's convention is to put platform-preloaded extensions in `extensions` (for clean search\_path defaults), with `vector` and `pgcrypto` in `public` (the standard Supabase pattern). Anything you `CREATE EXTENSION` yourself should also go in `extensions`.
If you're getting "function does not exist" errors after installing an extension, check the schema. Most extension functions don't end up in `public` and need to be qualified (`extensions.http_post(...)`) or have `extensions` added to your `search_path`.
## Next steps
The five schemas extensions might land in.
The most common use of a preloaded extension.
The pg\_net-based pattern that turns Postgres changes into HTTP calls.
The connection you'll use to install extensions.
# Knowledge Bases
Source: https://docs.powabase.ai/api-reference/knowledge-bases
Create and manage knowledge bases for semantic search and RAG.
Knowledge bases provide semantic search over your document content. They store chunked and embedded text from one or more sources for retrieval-augmented generation (RAG). When you add a source to a knowledge base, the platform chunks the source's page texts, generates vector embeddings, and stores them for similarity search.
## Common Patterns
Create a knowledge base, add one or more sources to trigger indexing, then use the search endpoint to query. Check indexing status by fetching the knowledge base details. Reindex when you change chunking parameters or want to re-process sources.
### GET /api/knowledge-bases
List all knowledge bases.
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/knowledge-bases", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/knowledge-bases' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/knowledge-bases
Create a new knowledge base. Only `name` is required. You can also pass `indexing_config` (how sources are chunked/embedded) and `retrieval_config` (how the KB is searched); anything you supply is merged over the strategy's defaults rather than replacing them. The retrieval-side features (**reranking**, **query enrichment**, and **multimodal retrieval**) all live inside `retrieval_config` and are persisted on the KB, so the minimal example and the fully-configured example below differ only in how much of that object you fill in.
```json Minimal theme={null}
{
"name": "Product Docs",
"description": "Product documentation"
}
```
```json Fully configured theme={null}
{
"name": "Product Docs",
"description": "Product documentation",
"indexing_config": {
"strategy": "chunk_embed",
"chunk_size": 2000,
"overlap": 50,
"embedding_model": "text-embedding-3-small"
},
"retrieval_config": {
"method": "hybrid",
"top_k": 5,
"vector_weight": 0.5,
"context_mode": "image",
"reranker": {
"model": "cohere/rerank-english-v3.0",
"candidate_count": 20
},
"query_enrichment": {
"enabled": true,
"model": "gpt-5-mini"
}
}
}
```
| `retrieval_config` field | Type | Notes |
| ------------------------ | ------ | ------------------------------------------------------------------------------------- |
| `method` | string | `vector_search` / `full_text` / `hybrid` / `tree_search`. |
| `top_k` | int | Results returned after any reranking. |
| `vector_weight` | float | Hybrid only: balance of semantic vs keyword (default `0.5`). |
| `context_mode` | string | `text` (default) or `image` for multimodal retrieval. All strategies except Doc2JSON. |
| `reranker` | object | `{ model, candidate_count }`. Present ⇒ reranking on; omit ⇒ off. |
| `query_enrichment` | object | `{ enabled, model }`. LLM query rewriting; `enabled` defaults to `false`. |
All of these are editable later via `PATCH` (see below). Reranking, query enrichment, and `context_mode` take effect on the next search with no reindex. Changing `indexing_config` (chunking, embedding model, strategy) requires a reindex to take effect. See [Knowledge bases & indexing](/concepts/knowledge-bases-indexing) for the per-strategy `indexing_config` fields.
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/knowledge-bases", headers=headers, json={"name": "Product Docs"})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases`, { method: "POST", headers, body: JSON.stringify({ name: "Product Docs" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"name": "Product Docs"}'
```
### GET /api/knowledge-bases/
Get a knowledge base with its indexed sources and status.
KB ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/knowledge-bases/{kb_id}", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/knowledge-bases/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PATCH /api/knowledge-bases/
Update knowledge base configuration or strategy. Accepts the same `name`, `description`, `indexing_config`, and `retrieval_config` fields as create. Send the full `retrieval_config` object you want; it is stored as-is. Reranking, query enrichment, and `context_mode` changes apply to the next search immediately; `indexing_config` changes need a reindex to take effect.
KB ID
```json Enable reranking + query enrichment on an existing KB theme={null}
{
"retrieval_config": {
"method": "hybrid",
"top_k": 5,
"reranker": { "model": "voyage/rerank-2.5", "candidate_count": 30 },
"query_enrichment": { "enabled": true }
}
}
```
```python Python theme={null}
response = requests.patch(f"{BASE_URL}/api/knowledge-bases/{kb_id}", headers=headers, json={"description": "Updated"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}`, { method: "PATCH", headers, body: JSON.stringify({ description: "Updated" }) });
```
```bash cURL theme={null}
curl -X PATCH '{BASE_URL}/api/knowledge-bases/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"description": "Updated"}'
```
### DELETE /api/knowledge-bases/
Delete a knowledge base and all its indexed data.
KB ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/knowledge-bases/{kb_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/knowledge-bases/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/knowledge-bases//sources
Paginated, filterable, sortable list of the sources indexed into a knowledge base. Each item joins the `indexed_sources` row with its underlying `sources` row, returning both index status and source metadata in one response.
KB ID
Case-insensitive substring match on the source's `name`.
Exact match on `index_status` (`pending`, `indexing`, `indexed`, `failed`, `cancelled`).
`name` or `created_at`. When omitted, sorts failed-first then newest-source-first, which surfaces problems at the top of a UI list.
`asc` or `desc` (default `desc`). Ignored when `sort` is omitted.
Page size. Defaults to 50, capped at 200.
Skip the first N rows. Defaults to 0.
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/knowledge-bases/{kb_id}/sources", headers=headers, params={"status": "failed", "limit": 20})
print(response.json())
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/sources?status=failed&limit=20`, { headers });
const { items, total } = await res.json();
```
```bash cURL theme={null}
curl '{BASE_URL}/api/knowledge-bases/{id}/sources?status=failed&limit=20' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
**Response:**
```json theme={null}
{
"items": [
{
"id": "indexed-source-uuid",
"source_id": "source-uuid",
"index_status": "failed",
"indexed_at": null,
"stats": {},
"error_message": "embedding provider rate-limited",
"source_name": "manual.pdf",
"file_type": "application/pdf",
"source_created_at": "2026-01-01T00:00:00Z"
}
],
"total": 1,
"limit": 20,
"offset": 0
}
```
`id` is the `indexed_sources.id` (use this with the cancel, reindex, and DELETE endpoints below). `source_id` is the underlying `ai.sources` row.
### POST /api/knowledge-bases//sources
Add a source to the knowledge base. Triggers asynchronous indexing.
KB ID
```json Request theme={null}
{ "source_id": "source-uuid" }
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/knowledge-bases/{kb_id}/sources", headers=headers, json={"source_id": source_id})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/sources`, { method: "POST", headers, body: JSON.stringify({ source_id: sourceId }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{id}/sources' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"source_id": "uuid"}'
```
### POST /api/knowledge-bases//sources//cancel
Cancel an in-progress indexing task. Only `pending` and `indexing` rows can be cancelled; anything already `indexed`, `failed`, or `cancelled` returns 409. Returns 404 if no `indexed_sources` row matches the given pair.
KB ID
The `indexed_sources.id` returned when the source was added (not the source UUID itself).
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/knowledge-bases/{kb_id}/sources/{indexed_source_id}/cancel", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/sources/${indexedSourceId}/cancel`, { method: "POST", headers });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{id}/sources/{indexed_source_id}/cancel' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### DELETE /api/knowledge-bases//sources/
Remove a source from a knowledge base. The underlying `ai.sources` row is **not** touched, only its link to this KB and everything the indexing pipeline produced from it. The source stays available to re-add to other KBs.
Deleting the `indexed_sources` row cascades through all eight derivative tables: `ai.chunks`, `ai.embeddings`, `ai.full_documents`, `ai.doc2json_documents`, `ai.page_index_toc`, `ai.page_index_nodes`, `ai.graph_index_toc`, `ai.graph_index_nodes`. After the call, the source contributes nothing to retrieval in this KB.
If the source is mid-indexing (status `pending` or `indexing` with a `celery_task_id`), the Celery task is revoked before the row is deleted. A revoke failure logs a warning but does not block the deletion; the row goes regardless. Returns `200` with a small JSON body, or `404` if no `indexed_sources` row matches the given `(kb_id, indexed_source_id)` pair.
KB ID
The `indexed_sources.id` returned when the source was added (not the source UUID itself).
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/knowledge-bases/{kb_id}/sources/{indexed_source_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/sources/${indexedSourceId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/knowledge-bases/{id}/sources/{indexed_source_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
**Response:**
```json theme={null}
{
"message": "Source removed from knowledge base",
"deleted_indexed_source_id": "indexed-source-uuid",
"kb_id": "kb-uuid"
}
```
### POST /api/knowledge-bases//reindex
Re-index sources in the knowledge base. The body is optional; an empty body re-indexes every source. Pass `indexed_source_ids` to re-index a specific subset, or `failed_only: true` to retry only sources currently in `failed` status.
KB ID
Restrict to specific `indexed_sources.id` values. If supplied, this wins and `failed_only` is ignored.
When true (and `indexed_source_ids` is empty), re-index only sources currently in `failed` status. Returns `{"status": "noop"}` if there are none.
```json Selective theme={null}
{ "indexed_source_ids": ["uuid-1", "uuid-2"] }
```
```json Failed only theme={null}
{ "failed_only": true }
```
```python Python theme={null}
# Re-index everything
requests.post(f"{BASE_URL}/api/knowledge-bases/{kb_id}/reindex", headers=headers)
# Retry only failed sources
requests.post(f"{BASE_URL}/api/knowledge-bases/{kb_id}/reindex", headers=headers, json={"failed_only": True})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/reindex`, { method: "POST", headers, body: JSON.stringify({ failed_only: true }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{id}/reindex' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"failed_only": true}'
```
### POST /api/knowledge-bases//build-bm25
Dispatch a one-shot BM25 rebuild for this KB. Re-tokenizes the entire item table for the KB's strategy (chunks / full\_documents / graph\_index\_nodes) and writes a fresh BM25 index, replacing whatever was there. This is an operator path: most KBs never need it, but it helps after changing `retrieval_config` tuning knobs or recovering from a partial index.
KB ID
Only valid for KBs whose `retrieval_config.method` is `hybrid` or `full_text`. Vector-only KBs return `400` (BM25 isn't part of their retrieval path).
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/knowledge-bases/{kb_id}/build-bm25", headers=headers)
print(response.json()) # {"task_id": "...", "knowledge_base_id": "..."}
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/build-bm25`, { method: "POST", headers });
const { task_id } = await res.json();
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{id}/build-bm25' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
Returns `202` + `{"task_id": "celery-task-uuid", "knowledge_base_id": "kb-uuid"}`. Poll the KB's `bm25_status` field via `GET /api/knowledge-bases/{id}` to observe completion. Returns `503` if the Celery worker can't be reached.
### POST /api/knowledge-bases//items
Fetch the indexed content items (chunks, nodes, or extracted JSON) for one or more source documents. The response shape depends on the KB's `indexing_config.strategy`; every item carries `id`, `source_id`, `text`, and `meta`, plus strategy-specific extras.
Use this endpoint to enumerate everything the platform produced from a source: for export, debugging, or to consume Doc2JSON `extracted_json` directly without going through retrieval.
KB ID
Non-empty list of source UUIDs (`sources.id`). Items from sources that were never added to this KB return zero rows for that ID rather than an error.
Max items to return. Default 1000, capped at 10000.
Pagination offset. Default 0.
```json Request theme={null}
{
"source_ids": ["src-uuid-1", "src-uuid-2"],
"limit": 1000,
"offset": 0
}
```
The `text` field is the embeddable representation of the item (full chunk text for `chunk_embed`, node body for `page_index`/`graph_index`, document summary for `full_document`/`doc2json`). Strategy-specific extras:
| Strategy | Source table | `text` is | Extra fields |
| --------------- | ----------------------- | ---------------- | ------------------------------------------------- |
| `chunk_embed` | `ai.chunks` | full chunk text | `chunk_index`, `start_char`, `end_char`, `tokens` |
| `page_index` | `ai.page_index_nodes` | node text | `node_id`, `title`, `depth`, `parent_node_id` |
| `graph_index` | `ai.graph_index_nodes` | node text | `node_id`, `title`, `depth`, `parent_node_id` |
| `full_document` | `ai.full_documents` | document summary | `full_text_path` |
| `doc2json` | `ai.doc2json_documents` | document summary | `extracted_json` |
```json Doc2JSON response theme={null}
{
"items": [
{
"id": "doc-uuid",
"source_id": "src-uuid-1",
"text": "Q3 2025 financials summary...",
"meta": { "page_count": 42 },
"extracted_json": {
"revenue_usd": 12345678,
"fiscal_period": "Q3-2025",
"key_risks": ["supply chain", "fx"]
}
}
],
"total": 1,
"strategy": "doc2json",
"source_ids": ["src-uuid-1"]
}
```
```json chunk_embed response theme={null}
{
"items": [
{
"id": "chunk-uuid",
"source_id": "src-uuid-1",
"text": "Lorem ipsum...",
"meta": {},
"chunk_index": 0,
"start_char": 0,
"end_char": 487,
"tokens": 112
}
],
"total": 348,
"strategy": "chunk_embed",
"source_ids": ["src-uuid-1"]
}
```
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/knowledge-bases/{kb_id}/items",
headers=headers,
json={"source_ids": [source_id], "limit": 1000},
)
data = response.json()
for item in data["items"]:
if data["strategy"] == "doc2json":
print(item["extracted_json"])
else:
print(item["text"])
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/items`, {
method: "POST",
headers,
body: JSON.stringify({ source_ids: [sourceId], limit: 1000 }),
});
const data = await res.json();
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{id}/items' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"source_ids": ["src-uuid"], "limit": 1000}'
```
### POST /api/knowledge-bases//search
Run a search against the knowledge base.
KB ID
```json Request theme={null}
{
"query": "search text",
"top_k": 5,
"retrieval_method": "hybrid",
"filter_metadata": { "topic": "billing" },
"similarity_threshold": 0.3,
"source_ids": ["src-uuid-1", "src-uuid-2"]
}
```
| Body field | Type | Notes |
| ---------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `query` | string (required) | Natural-language search query |
| `top_k` | int | Number of results to return. Default `5` (or `KB_DEFAULT_TOP_K` setting) |
| `retrieval_method` | string | `vector_search` / `full_text` / `hybrid` / `tree_search`. Omit to use the KB's configured default; the response then carries `retrieval_method: "auto"`. |
| `similarity_threshold` | float | Minimum vector score (0–1). Items below this are filtered out. Default `0.0`. |
| `filter_metadata` | object | Narrow to chunks whose enrichment-metadata fields match. Keys are field names from the KB's enrichment config (see `/enrichment`); values can be scalars or `{ "op": "...", "value": ... }` shapes. |
| `source_ids` | array of UUIDs | Restrict to chunks from this set of sources only. Useful for "search inside one specific document." |
Per-request overrides for retrieval tuning are also accepted: `vector_weight` (hybrid only), `context_mode` (`text` or `image`; see [Multimodal Retrieval](/concepts/knowledge-bases-indexing#multimodal-retrieval)), `ts_language` (full-text language for stemming), and reranker fields. These override the KB's stored `retrieval_config` for this one request only.
The reranker, query-enrichment, and multimodal (`context_mode`) behaviors are all properties stored on the KB's `retrieval_config`. Set them at creation or change them later with `PATCH`. See the create body below and the [knowledge bases concept guide](/concepts/knowledge-bases-indexing#reranking) for the full field reference.
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/knowledge-bases/{kb_id}/search", headers=headers, json={"query": "search text", "top_k": 5, "retrieval_method": "hybrid"})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/search`, { method: "POST", headers, body: JSON.stringify({ query: "search text", top_k: 5, retrieval_method: "hybrid" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{id}/search' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"query": "search text", "top_k": 5, "retrieval_method": "hybrid"}'
```
## Metadata Enrichment
Metadata enrichment runs an LLM over each indexed item to extract structured fields (text, boolean, number, enum) into a per-KB metadata table. The enriched values can then be used as filters in the `/search` `filter_metadata` parameter. PUT triggers re-enrichment only when something material changes (see the endpoint notes below).
### PUT /api/knowledge-bases//enrichment
Create or replace the enrichment config. Re-enrichment behavior depends on what changed:
* **Changes to `fields` or `llm_model`**: drop the metadata table, recreate it, and re-enrich every item from scratch. Returns 409 if a run is already in progress.
* **Toggle of `use_multimodal`**: keep the table, but re-enrich because results differ with/without images. Returns 409 if a run is in progress.
* **`max_tokens`-only change**: lightweight update, no re-enrichment.
* **Identical body**: no-op.
The response body is `{ "config": {...}, "re_enrichment_triggered": }`.
KB ID
Field definitions. Each: `{ name, description, type: "text"|"boolean"|"number"|"enum", enum_values?: string[] }`. `name` must be SQL-safe (alphanumeric + underscores, starts with a letter) and not in the reserved set (`id`, `item_id`, `item_type`, `enriched_at`, `_enrichment_error`). `enum` types require `enum_values` with at least 2 entries.
Model identifier (e.g. `gpt-4o`).
Max tokens per enrichment call.
When true, the enricher sees page images alongside text.
```json Request theme={null}
{
"fields": [
{ "name": "topic", "description": "Main topic of the chunk", "type": "text" },
{ "name": "is_legal", "description": "True if discusses legal matters", "type": "boolean" },
{ "name": "severity", "description": "Risk level", "type": "enum", "enum_values": ["low", "medium", "high"] }
],
"llm_model": "gpt-4o",
"max_tokens": 500,
"use_multimodal": false
}
```
```python Python theme={null}
requests.put(f"{BASE_URL}/api/knowledge-bases/{kb_id}/enrichment", headers=headers, json={"fields": [...], "llm_model": "gpt-4o"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/enrichment`, { method: "PUT", headers, body: JSON.stringify({ fields: [...], llm_model: "gpt-4o" }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/knowledge-bases/{id}/enrichment' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"fields": [{"name": "topic", "description": "Main topic", "type": "text"}], "llm_model": "gpt-4o"}'
```
### GET /api/knowledge-bases//enrichment
Get the current enrichment config and run status. Returns `{"config": null}` if no config exists. The `config` includes `status` (`idle`, `enriching`, `completed`, `completed_with_errors`, `failed`), `enriched_count`, `total_count`, and the dynamic `metadata_table_name`.
KB ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/knowledge-bases/{kb_id}/enrichment", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/enrichment`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/knowledge-bases/{id}/enrichment' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### DELETE /api/knowledge-bases//enrichment
Remove the enrichment config and drop its metadata table. Returns 404 if no enrichment config exists, or 409 if a run is in progress.
KB ID
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/knowledge-bases/{kb_id}/enrichment", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/enrichment`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/knowledge-bases/{id}/enrichment' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/knowledge-bases//enrichment/run
Manually trigger enrichment. With `incremental: true` only items missing metadata are processed; with `retry_failed: true` items previously marked failed are retried.
KB ID
Skip items that already have metadata.
Re-enrich items currently marked failed.
```json Request theme={null}
{ "incremental": true, "retry_failed": false }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/knowledge-bases/{kb_id}/enrichment/run", headers=headers, json={"incremental": True})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/enrichment/run`, { method: "POST", headers, body: JSON.stringify({ incremental: true }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{id}/enrichment/run' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"incremental": true}'
```
### GET /api/knowledge-bases//enrichment/results
Fetch enriched metadata for specific items. Pass a comma-separated `item_ids` query parameter. Returns per-item field values plus `item_errors` for any items that failed enrichment.
KB ID
Comma-separated item UUIDs (chunk/node IDs from `/items`).
```json Response theme={null}
{
"results": {
"chunk-uuid-1": { "topic": "billing", "is_legal": false, "severity": "low" }
},
"fields": [...],
"item_errors": {
"chunk-uuid-2": "LLM returned invalid JSON"
}
}
```
```python Python theme={null}
ids = ",".join([chunk_a, chunk_b])
requests.get(f"{BASE_URL}/api/knowledge-bases/{kb_id}/enrichment/results?item_ids={ids}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/enrichment/results?item_ids=${ids}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/knowledge-bases/{id}/enrichment/results?item_ids=uuid1,uuid2' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Graph-index re-enrichment
Specific to KBs whose `indexing_config.strategy` is `graph_index`. These endpoints re-run reference enrichment (Stages 2+3) without a full reindex.
### POST /api/knowledge-bases//graph-enrichment/run
Re-run graph reference enrichment. Optionally limit to a single `indexed_source_id`, or set `retry_failed: true` to retry only the previously-failed references. Returns 400 if the KB strategy is not `graph_index`.
KB ID
Limit to a single source's references.
Retry only previously-failed references.
```python Python theme={null}
requests.post(f"{BASE_URL}/api/knowledge-bases/{kb_id}/graph-enrichment/run", headers=headers, json={"retry_failed": True})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/graph-enrichment/run`, { method: "POST", headers, body: JSON.stringify({ retry_failed: true }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{id}/graph-enrichment/run' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"retry_failed": true}'
```
### GET /api/knowledge-bases//graph-enrichment/errors
Per-source enrichment error counts. Useful for surfacing which sources need retry. Returns 400 if the KB is not `graph_index`.
KB ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/knowledge-bases/{kb_id}/graph-enrichment/errors", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/graph-enrichment/errors`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/knowledge-bases/{id}/graph-enrichment/errors' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Discoverable defaults
### GET /api/config/kb-defaults
Returns the platform's KB-creation defaults so a UI or self-service KB-creation flow can present valid options without hardcoding them. The Studio's KB-create wizard uses this endpoint.
Returns:
* `strategies`: map of strategy name → `{ label, compatible_retrievers, retriever_labels, default_retrieval_method, supports_reranker, default_indexing_config, default_retrieval_config }`. The strategies are the indexing strategies described in [Knowledge bases & indexing](/concepts/knowledge-bases-indexing).
* `reranker`: `{ default_model, candidate_count, options }`. `options` is the list of supported reranker models (Cohere v3, Jina v2, Voyage 2.5, ZeroEntropy zerank-2, etc.).
* `query_enrichment`: `{ model }` (the model used to rewrite/expand user queries).
* `enrichment`: `{ model, max_tokens }` (the LLM used for chunk metadata enrichment).
* `hybrid_vector_weight`: default weight for hybrid search (vector vs. sparse contribution).
* `extraction`: `{ default_method, fallback_chain, options }`. `options` lists every supported extraction method (mistral, paddleocr, lighton, opendataloader, fitz, pdfplumber, plus `auto`) with a one-line description of each.
A new strategy, reranker, or extraction method shows up here without any docs update, so this is the place to read what's selectable when creating or reconfiguring a KB.
```python Python theme={null}
defaults = requests.get(f"{BASE_URL}/api/config/kb-defaults", headers=headers).json()
print(list(defaults["strategies"].keys()))
print([opt["value"] for opt in defaults["extraction"]["options"]])
```
```typescript TypeScript theme={null}
const defaults = await fetch(`${BASE_URL}/api/config/kb-defaults`, { headers }).then(r => r.json());
```
```bash cURL theme={null}
curl '{BASE_URL}/api/config/kb-defaults' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Error Responses
KB routes return `{"error": ""}` (no structured error code field).
| Status | Description |
| ------ | --------------------------------------------------------------------------------------------------------------- |
| 400 | Invalid chunking, embedding, or enrichment field configuration |
| 400 | Endpoint requires a specific `indexing_config.strategy` (e.g. graph-enrichment endpoints require `graph_index`) |
| 404 | No knowledge base or indexed source exists with the given ID |
| 404 | No enrichment config exists (DELETE `/enrichment`, GET `/enrichment/results`) |
| 409 | Indexing for this source has already finished or been cancelled (`/sources/{id}/cancel`) |
| 409 | Cannot update or delete enrichment config while a run is active |
| 503 | Failed to dispatch the indexing task (worker unavailable) |
# Orchestrations
Source: https://docs.powabase.ai/api-reference/orchestrations
Combine multiple agents into coordinated multi-agent systems.
Orchestrations coordinate multiple agents across multi-domain tasks. A coordinator agent analyzes incoming messages and delegates subtasks to specialized entity agents based on their role descriptions, then synthesizes the entity responses into a single reply.
## Common Patterns
Create an orchestration, add entity agents with clear role descriptions, then use the streaming endpoint. The coordinator handles delegation. Keep entity role descriptions specific and non-overlapping so the coordinator can route cleanly.
## CRUD
### POST /api/orchestrations
Create an orchestration.
```json Request theme={null}
{ "name": "Team", "strategy": "supervisor" }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/orchestrations", headers=headers, json={"name": "Team", "strategy": "supervisor"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations`, { method: "POST", headers, body: JSON.stringify({ name: "Team", strategy: "supervisor" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/orchestrations' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"name": "Team"}'
```
### GET /api/orchestrations
List all orchestrations.
```python Python theme={null}
requests.get(f"{BASE_URL}/api/orchestrations", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/orchestrations' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/orchestrations/
Get orchestration with its entities.
Orchestration ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/orchestrations/{orch_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/orchestrations/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PUT /api/orchestrations/
Update orchestration config.
Orchestration ID
```python Python theme={null}
requests.put(f"{BASE_URL}/api/orchestrations/{orch_id}", headers=headers, json={"name": "Updated"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}`, { method: "PUT", headers, body: JSON.stringify({ name: "Updated" }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/orchestrations/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"name": "Updated"}'
```
### DELETE /api/orchestrations/
Delete an orchestration.
Orchestration ID
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/orchestrations/{orch_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/orchestrations/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Entities
### POST /api/orchestrations//entities
Add an agent as an entity.
Orchestration ID
Use `"agent"`. The route accepts any string ≤50 chars, but the orchestrator's execution path only invokes entities where `entity_type === "agent"`; other values are stored but silently skipped at run time.
UUID of the agent to add.
Free-text role hint shown to the coordinator when delegating. Replaces the prior `role` field used in older docs examples.
Per-entity overrides; defaults to `{}`.
Sort order in list responses; defaults to `0`.
The route returns 400 if either `entity_type` or `entity_ref_id` is missing. List/PUT responses key off `entity_ref_id` and `role_description`, so any client trying to round-trip will see the same field names.
```json Request theme={null}
{
"entity_type": "agent",
"entity_ref_id": "agent-uuid",
"role_description": "Handles billing"
}
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/orchestrations/{orch_id}/entities", headers=headers, json={"entity_type": "agent", "entity_ref_id": agent_id, "role_description": "Handles billing"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/entities`, { method: "POST", headers, body: JSON.stringify({ entity_type: "agent", entity_ref_id: agentId, role_description: "Handles billing" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/orchestrations/{id}/entities' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"entity_type": "agent", "entity_ref_id": "agent-uuid", "role_description": "Handles billing"}'
```
### GET /api/orchestrations//entities
List entities in the orchestration.
Orchestration ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/orchestrations/{orch_id}/entities", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/entities`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/orchestrations/{id}/entities' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PUT /api/orchestrations//entities/
Update an entity. Only `role_description`, `config`, and `position` are writable; any other top-level keys are silently ignored. `entity_type` and `entity_ref_id` are not updatable; create a new entity instead.
Orchestration ID
Entity ID
Free-text role hint shown to the coordinator when delegating.
Per-entity overrides.
Sort order in list responses.
```json Request theme={null}
{ "role_description": "Updated role" }
```
```python Python theme={null}
requests.put(f"{BASE_URL}/api/orchestrations/{orch_id}/entities/{entity_id}", headers=headers, json={"role_description": "Updated role"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/entities/${entityId}`, { method: "PUT", headers, body: JSON.stringify({ role_description: "Updated role" }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/orchestrations/{id}/entities/{eid}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"role_description": "Updated role"}'
```
### DELETE /api/orchestrations//entities/
Remove an entity.
Orchestration ID
Entity ID
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/orchestrations/{orch_id}/entities/{entity_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/entities/${entityId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/orchestrations/{id}/entities/{eid}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Execution
### POST /api/orchestrations//run/stream
Run orchestration with streaming SSE. Includes delegation events.
Accepts `runtime_knowledge_bases` — the same query-scoped KB references as the [agent streaming endpoint](/api-reference/agents#runtime-knowledge-base-references), with the same entry shape, validation, and 10-entry cap. The references flow to **every sub-agent** in the orchestration: each sub-agent's `knowledge_search` tool merges them with its own attached KBs for this request only (a sub-agent with no attached KBs gains the tool for the run). One caveat differs per sub-agent: an entry's `max_context_tokens` is honored only for sub-agents whose search tool resolves to exactly one KB.
Orchestration ID
```json Request theme={null}
{ "message": "Hello" }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/orchestrations/{orch_id}/run/stream", headers=headers, json={"message": "Hello"}, stream=True)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/run/stream`, { method: "POST", headers, body: JSON.stringify({ message: "Hello" }) });
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/orchestrations/{id}/run/stream' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"message": "Hello"}'
```
### GET /api/orchestrations/runs/
Get orchestration run result.
Run ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/orchestrations/runs/{run_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/runs/${runId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/orchestrations/runs/{run_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Hooks
These endpoints register hooks against an orchestration using the same `ai.hooks` model as [agent hooks](/api-reference/agents#hooks): `event`, `type`, `config`, `matcher`, `enabled`, and `position`.
**Orchestration hooks are not executed yet.** The CRUD endpoints below work (you can create, list, and delete hook rows), but the orchestration engine does not currently fire them at runtime, so a registered hook has no effect on an orchestration run today. Register them only for forward-compatibility. When execution lands, orchestration hooks will follow the [agent hook contract](/concepts/agents-tools#hooks--middleware) (events such as `OnRunStart`/`OnRunComplete`, types `http`/`rule`/`approval`). The `event` and `type` you send are stored verbatim and not validated.
Hooks attached to a **member agent** also do not fire while that agent runs *inside* an orchestration; only [standalone agent runs](/api-reference/agents#hooks) execute hooks today. If you rely on an agent's approval or policy hook, run that agent directly rather than as part of an orchestration until this lands.
### POST /api/orchestrations//hooks
Add a hook.
Orchestration ID
Lifecycle event name. Stored verbatim; not validated.
Hook handler type: `http`, `rule`, or `approval` (see the agent hook contract).
Handler-specific configuration (for `http`, includes `url`).
Optional filter narrowing when the hook fires.
Defaults to `true`.
Execution order within the event. Defaults to `0`.
```json Request theme={null}
{
"event": "OnRunComplete",
"type": "http",
"config": { "url": "https://example.com/orch-hook" },
"enabled": true,
"position": 0
}
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/orchestrations/{orch_id}/hooks", headers=headers, json={"event": "OnRunComplete", "type": "http", "config": {"url": "https://example.com/hook"}})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/hooks`, { method: "POST", headers, body: JSON.stringify({ event: "OnRunComplete", type: "http", config: { url: "https://example.com/hook" } }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/orchestrations/{id}/hooks' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"event": "OnRunComplete", "type": "http", "config": {"url": "..."}}'
```
### GET /api/orchestrations//hooks
List hooks for the orchestration, ordered by `position`.
Orchestration ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/orchestrations/{orch_id}/hooks", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/hooks`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/orchestrations/{id}/hooks' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### DELETE /api/orchestrations//hooks/
Remove a hook.
Orchestration ID
Hook ID
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/orchestrations/{orch_id}/hooks/{hook_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/hooks/${hookId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/orchestrations/{id}/hooks/{hook_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Sessions
Session-level reads on orchestrations. The streaming `/run/stream` endpoint returns a `session_id` you can use here to fetch history.
### GET /api/orchestrations//sessions
List the most recent 100 sessions for an orchestration. Each entry includes `session_id`, `run_count`, `first_message`, `last_activity_at`, and `created_at`.
Orchestration ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/orchestrations/{orch_id}/sessions", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/sessions`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/orchestrations/{id}/sessions' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/orchestrations//sessions//messages
Assembled messages for a session. Each assistant message carries per-run reasoning replay metadata (`reasoning_requested`, `reasoning_duration_ms`, `reasoning`, `events`) so a chat UI can re-render the original "Thought for X.Xs" pill on refresh. Tool calls are attached per-message via `tool_calls`. The legacy top-level `events` field is preserved for older clients.
Orchestration ID
Session ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/orchestrations/{orch_id}/sessions/{session_id}/messages", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/sessions/${sessionId}/messages`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/orchestrations/{id}/sessions/{session_id}/messages' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Error Responses
Orchestration routes return `{"error": ""}` (no structured error code field).
| Status | Description |
| ------ | ----------------------------------------------------------------------- |
| 400 | The orchestration has no entity agents; add at least one before running |
| 400 | Hook POST is missing one of `event`, `type`, or `config` |
| 404 | No orchestration exists with the given ID |
| 404 | No hook exists with the given ID for this orchestration |
| 404 | No orchestration session with the given ID |
# Database (PostgREST)
Source: https://docs.powabase.ai/api-reference/postgrest
Direct REST access to your project's public schema. PostgREST exposes every table and view as a REST endpoint with filtering, ordering, pagination, and embedded relations.
Each project has its own Postgres database. The `ai` schema is managed by Powabase (sources, knowledge bases, agents, sessions, and so on). The `public` schema is yours: create tables, define relationships, and add indexes, and PostgREST exposes them at /rest/v1/\{table}. PostgREST honours Row Level Security. The Anon (Publishable) Key respects RLS policies; the Service Role (Secret) Key bypasses them, so use the service role server-side only. Both keys, plus the Project URL, are in the Studio's Connect modal: click the Connect button in your project header, or append ?showConnect=true to any project URL.
## Common Patterns
Read with GET /rest/v1/\{table} and a select= query parameter. Insert with POST and a JSON body. Update with PATCH and a filter. Delete with DELETE and a filter. Embed related tables with select=*,other(*). Filter operators (eq, gt, lt, like, in, is, ...) follow PostgREST conventions. Always include both apikey and Authorization: Bearer headers, both set to the same key; sending only one returns 401.
## Reading rows
### GET /rest/v1/
List rows from a table or view. Use select= to project columns, filter operators (eq, gt, like, in, ...) to filter, order= to sort, limit and offset for pagination. Combine select with embedded relations to fetch joined data in a single request.
Table or view name in the public schema
Comma-separated columns. Use *,relation(*) to embed related rows. Default: \*
Order by column, e.g. created\_at.desc
Max rows to return
Skip the first N rows
```python Python theme={null}
response = requests.get(f"{BASE_URL}/rest/v1/users?select=id,email&limit=20", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/rest/v1/users?select=id,email&limit=20`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/rest/v1/users?select=id,email&limit=20' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /rest/v1/?id=eq.
Read a single row by primary key (or any unique column) using the eq filter. Add Accept: application/vnd.pgrst.object+json to receive a single object instead of an array.
Table or view name
```python Python theme={null}
response = requests.get(f"{BASE_URL}/rest/v1/users?id=eq.{user_id}", headers={**headers, "Accept": "application/vnd.pgrst.object+json"})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/rest/v1/users?id=eq.${userId}`, { headers: { ...headers, Accept: "application/vnd.pgrst.object+json" } });
```
```bash cURL theme={null}
curl '{BASE_URL}/rest/v1/users?id=eq.{user_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Accept: application/vnd.pgrst.object+json"
```
## Writing rows
### POST /rest/v1/
Insert one or many rows. Pass a single JSON object or an array. Add Prefer: return=representation to receive the inserted rows back. Use Prefer: resolution=merge-duplicates with on\_conflict= for upsert semantics.
Target table
```json Request theme={null}
{ "email": "ana@acme.io", "role": "admin" }
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/rest/v1/users", headers={**headers, "Prefer": "return=representation"}, json={"email": "ana@acme.io", "role": "admin"})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/rest/v1/users`, { method: "POST", headers: { ...headers, Prefer: "return=representation" }, body: JSON.stringify({ email: "ana@acme.io", role: "admin" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/rest/v1/users' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -H "Prefer: return=representation" -d '{"email": "ana@acme.io", "role": "admin"}'
```
### PATCH /rest/v1/
Update rows that match the filter in the query string. Always include a filter; without one, PATCH updates every row in the table.
Target table
```json Request theme={null}
{ "role": "viewer" }
```
```python Python theme={null}
response = requests.patch(f"{BASE_URL}/rest/v1/users?id=eq.{user_id}", headers=headers, json={"role": "viewer"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/rest/v1/users?id=eq.${userId}`, { method: "PATCH", headers, body: JSON.stringify({ role: "viewer" }) });
```
```bash cURL theme={null}
curl -X PATCH '{BASE_URL}/rest/v1/users?id=eq.{user_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"role": "viewer"}'
```
### DELETE /rest/v1/
Delete rows that match the filter in the query string. Always include a filter; without one, DELETE removes every row.
Target table
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/rest/v1/users?id=eq.{user_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/rest/v1/users?id=eq.${userId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/rest/v1/users?id=eq.{user_id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Stored procedures
### POST /rest/v1/rpc/
Call a Postgres function (stored procedure) defined in the public schema. Pass arguments as a JSON body. Functions returning a table are listable like a regular endpoint.
Postgres function name
```json Request theme={null}
{ "arg1": "value", "arg2": 42 }
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/rest/v1/rpc/get_user_stats", headers=headers, json={"user_id": "..."})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/rest/v1/rpc/get_user_stats`, { method: "POST", headers, body: JSON.stringify({ user_id: "..." }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/rest/v1/rpc/get_user_stats' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"user_id": "..."}'
```
## Error Responses
| Status | Code | Description |
| ------ | ----------------- | ------------------------------------------------------------ |
| 401 | `unauthorized` | Missing or invalid apikey/Authorization headers |
| 403 | `rls_denied` | Row Level Security policy denied the operation for this user |
| 404 | `not_found` | Table or view does not exist in the public schema |
| 409 | `conflict` | Insert violated a unique constraint or foreign key |
| 422 | `invalid_request` | Malformed filter syntax or invalid column reference |
# Realtime
Source: https://docs.powabase.ai/api-reference/realtime
WebSocket protocol for subscribing to Broadcast, Presence, and Postgres Changes events, plus the REST endpoint for server-side message emission.
The Realtime API is Supabase Realtime v2.65.3, mounted at two endpoints on every project:
* **WebSocket:** `wss://{ref}.p.powabase.ai/realtime/v1/websocket` for subscriptions.
* **REST:** `https://{ref}.p.powabase.ai/realtime/v1/api` for server-side broadcast.
For the conceptual model, see [Realtime model](/concepts/realtime). For worked subscription patterns, see [Realtime subscriptions](/guides/realtime-subscriptions).
## Authentication
Realtime authenticates the WebSocket at upgrade time via query parameters. There are no headers on the WS handshake because most browser WebSocket APIs don't let you set them.
```
wss://{ref}.p.powabase.ai/realtime/v1/websocket?apikey=&vsn=1.0.0
```
| Parameter | Required | Meaning |
| ----------- | -------- | ------------------------------------------------------------------------------------ |
| `apikey` | Yes | The Anon Key, a signed-in user's access token, or the Service Role Key |
| `vsn` | Yes | Protocol version. Use `1.0.0`. |
| `log_level` | No | `error`, `warn`, `info`, `debug`. Server-side logging verbosity for this connection. |
The REST endpoint uses standard headers (`apikey` plus `Authorization: Bearer `) and requires the Service Role Key, not the Anon Key.
## Frame format
All WebSocket frames are Phoenix Channels JSON envelopes:
```json theme={null}
{
"topic": "realtime:public:orders",
"event": "phx_join",
"payload": { ... },
"ref": "1"
}
```
* **`topic`**: the channel name. Convention is `realtime::` for postgres\_changes channels, or any arbitrary string for broadcast/presence.
* **`event`**: what kind of frame (see below).
* **`payload`**: event-specific data.
* **`ref`**: client-chosen ID that the server echoes back on `phx_reply`. Useful for correlating sends with their acknowledgments.
## WebSocket events
### phx\_join
Client → server. Subscribes to a channel.
```json theme={null}
{
"topic": "realtime:public:orders",
"event": "phx_join",
"payload": {
"config": {
"broadcast": { "self": false, "ack": true },
"presence": { "key": "" },
"postgres_changes": [
{ "event": "*", "schema": "public", "table": "orders", "filter": "user_id=eq." }
],
"private": false
}
},
"ref": "1"
}
```
All three config sections are optional. Omit them entirely and you get a bare channel you can broadcast to without receiving any of the three event types, useful for chat-room-style one-way emit.
**`config.broadcast`:**
* `self` (bool, default `false`): receive your own broadcast messages.
* `ack` (bool, default `false`): receive `phx_reply` confirming the broadcast was routed.
**`config.presence`:**
* `key` (string): unique identifier for this presence (typically the user id). Used for dedupe across multiple connections.
**`config.postgres_changes`**, array of filter specs:
* `event` (string): `INSERT`, `UPDATE`, `DELETE`, or `*` for all three.
* `schema` (string): target schema. `public` is the common case.
* `table` (string): target table.
* `filter` (string, optional): column equality in PostgREST-style syntax (e.g., `user_id=eq.`, `status=eq.active`).
**`config.private`** (bool, default `false`): when `true`, Realtime checks the SELECT policy on `realtime.messages` before letting you join. Without a passing policy, the join returns an error.
### phx\_reply
Server → client. Acknowledgment for a `phx_join`, broadcast, or other client-initiated frame.
```json theme={null}
{
"topic": "realtime:public:orders",
"event": "phx_reply",
"payload": {
"status": "ok", // or "error"
"response": { ... }
},
"ref": "1"
}
```
The `ref` matches the client's send. On error, `response` includes `{ reason: "" }`.
### postgres\_changes
Server → client. A row change matching a previously-subscribed postgres\_changes config.
```json theme={null}
{
"topic": "realtime:public:orders",
"event": "postgres_changes",
"payload": {
"data": {
"schema": "public",
"table": "orders",
"commit_timestamp": "2026-05-29T12:00:00Z",
"eventType": "INSERT",
"new": { "id": "...", "user_id": "...", "amount": 100 },
"old": { },
"errors": null
}
},
"ref": null
}
```
For `UPDATE`, both `new` and `old` are populated. For `DELETE`, only `old` is. The columns the client sees depend on the table's REPLICA IDENTITY: `DEFAULT` shows changed columns plus the primary key on UPDATE; `FULL` shows every column. To set `FULL`:
```sql theme={null}
ALTER TABLE public.orders REPLICA IDENTITY FULL;
```
### broadcast
Client → server, and server → client. Custom messages on the channel.
Client send:
```json theme={null}
{
"topic": "chat:room-42",
"event": "broadcast",
"payload": {
"type": "broadcast",
"event": "chat_message",
"payload": { "user_id": "...", "text": "hello" }
},
"ref": "5"
}
```
Server delivery (to other subscribers):
```json theme={null}
{
"topic": "chat:room-42",
"event": "broadcast",
"payload": {
"event": "chat_message",
"payload": { "user_id": "...", "text": "hello" },
"type": "broadcast"
},
"ref": null
}
```
The nested `event` field inside `payload` is your custom event type (e.g., `chat_message`, `typing`, `cursor_move`). The outer `event` is always `broadcast`.
### presence\_state
Server → client. Initial snapshot of who's currently tracked in the channel, sent right after a successful `phx_join` with `presence` config.
```json theme={null}
{
"topic": "presence:doc-abc",
"event": "presence_state",
"payload": {
"user-uuid-1": [{ "user_id": "...", "display_name": "Alice", ... }],
"user-uuid-2": [{ "user_id": "...", "display_name": "Bob", ... }]
},
"ref": null
}
```
The top-level keys are the `key` values from each presence, typically user ids. The values are arrays because the same key can be tracked from multiple connections (e.g., two browser tabs by the same user).
### presence\_diff
Server → client. Incremental changes to the presence state after the initial `presence_state`.
```json theme={null}
{
"topic": "presence:doc-abc",
"event": "presence_diff",
"payload": {
"joins": { "user-uuid-3": [{ ... }] },
"leaves": { "user-uuid-2": [{ ... }] }
},
"ref": null
}
```
Client → server, to update your own presence:
```json theme={null}
{
"topic": "presence:doc-abc",
"event": "presence_diff",
"payload": { "action": "track", "data": { ... } },
"ref": "8"
}
```
`action` is `track` or `untrack`. `data` is what other subscribers see in `presence_state`.
### system
Server → client. Notifications about the channel itself (subscribed, unsubscribed, errors).
```json theme={null}
{
"topic": "realtime:public:orders",
"event": "system",
"payload": {
"extension": "postgres_changes",
"status": "ok",
"message": "Subscribed to PostgreSQL"
},
"ref": null
}
```
Use this to confirm the postgres\_changes subscription is actually live. A `system` frame with `status: "error"` here usually means the publication isn't set up. See [Realtime model](/concepts/realtime).
### phoenix heartbeat
Client → server. The Phoenix Channels convention is a heartbeat every 30 seconds; if no heartbeat arrives for roughly two intervals (the Phoenix default, which Powabase doesn't override), Realtime closes the connection. Most clients send heartbeats automatically.
```json theme={null}
{
"topic": "phoenix",
"event": "heartbeat",
"payload": {},
"ref": "9"
}
```
The server responds with a `phx_reply` of `{ status: "ok" }`. If you don't get an OK within a few seconds, the connection is probably half-open; disconnect and reconnect.
### phx\_leave
Client → server. Cleanly leave a channel without closing the WebSocket.
```json theme={null}
{
"topic": "realtime:public:orders",
"event": "phx_leave",
"payload": {},
"ref": "10"
}
```
### phx\_close
Server → client. Channel was closed (by you, by an error, or by the server). After this, send a new `phx_join` to re-subscribe.
## REST: broadcast
For server-side message emission. Use the Service Role Key.
### POST /realtime/v1/api/broadcast
Send one or more broadcast messages to channels.
Array of message objects.
Each message object:
Channel name to broadcast on.
Your custom event type (matches the inner `event` field clients see).
Your message data.
Default `false`. When `true`, only clients subscribed to a private version of the channel receive the message.
```json Request theme={null}
{
"messages": [
{ "topic": "chat:room-42", "event": "system_announcement", "payload": { "text": "Server maintenance in 5 min" }, "private": false }
]
}
```
```json Multiple theme={null}
{
"messages": [
{ "topic": "orders:user-1", "event": "new_order", "payload": { "order_id": "abc" } },
{ "topic": "orders:user-2", "event": "new_order", "payload": { "order_id": "def" } }
]
}
```
```python Python theme={null}
requests.post(
f"{BASE_URL}/realtime/v1/api/broadcast",
headers={"apikey": SERVICE_ROLE_KEY, "Authorization": f"Bearer {SERVICE_ROLE_KEY}", "Content-Type": "application/json"},
json={"messages": [
{"topic": "chat:room-42", "event": "system_announcement", "payload": {"text": "Maintenance in 5 min"}},
]},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL_HTTP}/realtime/v1/api/broadcast`, {
method: "POST",
headers: { apikey: SERVICE_ROLE_KEY, Authorization: `Bearer ${SERVICE_ROLE_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ topic: "chat:room-42", event: "system_announcement", payload: { text: "Maintenance in 5 min" } }] }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/realtime/v1/api/broadcast' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type: application/json" \
-d '{"messages":[{"topic":"chat:room-42","event":"system_announcement","payload":{"text":"Maintenance in 5 min"}}]}'
```
**Response:** `{ "message": "ok" }` on success. The endpoint is fire-and-forget: it doesn't tell you how many subscribers received the message.
## SQL: realtime.send() and realtime.broadcast\_changes()
The Realtime image installs two functions in the `realtime` schema that let your Postgres triggers emit broadcast messages directly:
### realtime.send(payload jsonb, event text, topic text, private boolean default false)
Send a single broadcast message from SQL. Equivalent to a single-message POST to `/realtime/v1/api/broadcast`.
```sql theme={null}
PERFORM realtime.send(
payload => jsonb_build_object('order_id', NEW.id, 'amount', NEW.amount),
event => 'order_created',
topic => 'orders:' || NEW.user_id::text,
private => true
);
```
### realtime.broadcast\_changes(topic text, event text, op text, table text, schema text, new record, old record, level text default 'topic')
Higher-level wrapper for "broadcast this row change." Designed to be called from a trigger function (see [Realtime model](/concepts/realtime) for a worked trigger example).
The `level` parameter is the channel-privacy level:
* `'topic'` (default): sends to a private channel; subscribers need a passing RLS policy on `realtime.messages` to receive.
* Any other string: treated as public.
## Channel-private auth: realtime.messages
Private channels gate join with a SELECT policy on `realtime.messages`. The function `realtime.topic()` returns the channel name being checked, so the policy can condition the decision on the topic:
```sql theme={null}
-- Only members of the room can join the room channel
CREATE POLICY only_room_members ON realtime.messages
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.room_members
WHERE user_id = auth.uid()
AND room_id::text = split_part(realtime.topic(), ':', 2)
)
);
```
For more patterns, see the [RLS Cookbook](/guides/rls-policies).
## Error responses
WebSocket errors arrive as `phx_reply` frames with `payload.status: "error"`:
| Reason (in `payload.response.reason`) | When |
| ------------------------------------- | ---------------------------------------------------------------------------------- |
| `unauthorized` | The JWT failed signature verification or has expired |
| `unmatched_topic` | The topic shape doesn't match a known pattern (e.g., empty string) |
| `invalid_join_payload` | The `config` block is malformed |
| `private_unauthorized` | `private: true` but no matching SELECT policy on `realtime.messages` |
| `pg_publication_missing` | Subscribed to `postgres_changes` but `supabase_realtime` publication doesn't exist |
| `pg_filter_invalid` | The `filter` syntax doesn't parse (e.g., wrong operator) |
WebSocket connection errors:
| HTTP status | Reason | When |
| ----------- | ---------------------- | --------------------------------------------------------------------------------------------------------- |
| 403 | `TenantNotFound` | Kong didn't preserve the Host header. Self-hosted only; on Powabase managed cloud this is a platform bug. |
| 401 | Bad apikey query param | The `apikey` is missing, malformed, or rejected |
REST errors: standard JSON shape, `{ "error": "" }`.
## Service versions and notes
* Realtime: `v2.65.3`
* Both routes (`/realtime/v1/` and `/realtime/v1/api`) use Kong's `preserve_host: true` to let Realtime parse `tenant_id` from the Host header.
* Per-project Realtime pods are seeded with `SEED_SELF_HOST=true`, so the tenant is created on first connect rather than requiring an out-of-band provisioning step.
* The Realtime `DB_ENC_KEY` is shared across Powabase deployments. It's an internal multi-tenant encryption key, not a per-project secret, and isn't user-relevant.
## Next steps
The conceptual underpinning: three channels, auth, the publication gotcha.
Three worked patterns in TypeScript with the protocol details from this page applied.
The policies that gate private channels and the underlying tables for Postgres Changes.
Where the access tokens that authenticate Realtime connections come from.
# Sessions
Source: https://docs.powabase.ai/api-reference/sessions
Manage multi-turn chat sessions and their message/run history.
Sessions store the conversation history between users and agents. Each session contains messages (user inputs, assistant responses, tool calls, tool results, plus the retrieved context that grounded each assistant turn) and can span multiple agent runs. When the agent is configured for reasoning, sessions also track reasoning configuration per run. Sessions persist until explicitly deleted.
## Common Patterns
Sessions are created automatically when you run an agent without a session\_id. To continue a conversation, pass the session\_id from the start event of a previous run. Retrieve message history with GET /api/sessions/\{id}/messages to display conversation context.
### GET /api/sessions/
Get a session by ID.
Session ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sessions/{session_id}", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sessions/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/sessions//messages
Get assembled chat messages for a session. Each assistant message includes its retrieved\_context (knowledge-base chunks fetched during the run) so you can surface citations alongside replies.
Session ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sessions/{session_id}/messages", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sessions/${sessionId}/messages`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sessions/{id}/messages' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/sessions//runs
List all agent runs within a session.
Session ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sessions/{session_id}/runs", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sessions/${sessionId}/runs`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sessions/{id}/runs' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/sessions//runs//retrieved-context
Get the retrieved context (knowledge-base chunks fetched during retrieval) for a single run within a session. Useful for debugging RAG behavior: you can see which chunks the agent saw before generating a given turn, separate from the assembled `/messages` view.
Session ID
Run ID (the `run_id` of an `agent_run` belonging to this session)
```json Response theme={null}
{
"session_id": "sess_abc",
"run_id": "run_xyz",
"retrieved_context": [
{ "_type": "retrieval_diagnostics", "...": "..." },
{
"id": "chunk-uuid",
"text": "...",
"score": 0.84,
"retrieval_score": 0.71,
"reranker_score": 0.84,
"source_id": "src-uuid",
"source_name": "Q3-2025.pdf",
"knowledge_base_id": "kb-uuid",
"kb_name": "Finance docs",
"indexing_strategy": "chunk_embed",
"meta": {},
"included_in_context": true
}
]
}
```
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sessions/{session_id}/runs/{run_id}/retrieved-context", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sessions/${sessionId}/runs/${runId}/retrieved-context`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sessions/{id}/runs/{run_id}/retrieved-context' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### DELETE /api/sessions/
Delete a session and all its runs.
Session ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/sessions/{session_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/sessions/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Error Responses
Errors return `{"error": ""}`.
| Status | Description |
| ------ | ------------------------------------------------------------------------------------------------------------------------- |
| 404 | No session exists with the given ID, or the session is owned by another user (returned as 404 to avoid leaking existence) |
| 404 | The given run does not exist within this session (`/runs/{run_id}/retrieved-context`) |
# Settings
Source: https://docs.powabase.ai/api-reference/settings
Read and override per-project configuration values managed through a typed registry.
Project Settings expose a typed registry of configuration knobs (extraction defaults, copilot behavior, model selection, etc.). The registry defines each setting's default; user overrides are stored per-project and merged at read time. Secret-typed settings are returned with a mask placeholder, and sending that placeholder back is treated as "no change".
Setting keys come from the registry as-is. Most use `UPPER_SNAKE_CASE` (e.g. `EXTRACTION_DEFAULT_METHOD`, `COPILOT_TEMPERATURE`), with a few historical lowercase keys (e.g. `copilot_model`). All values are persisted as strings, so when sending bools or numbers, stringify them (`"true"`, `"0.7"`).
Categories defined by the registry: `copilot`, `agents`, `tools`, `knowledge-indexing`, `knowledge-retrieval`, `compaction`, `sources`.
## Common Patterns
Use GET to discover the full set of settings (with their defaults, current values, types, and category metadata). Use PUT for bulk updates from a settings UI. Use DELETE on a key to revert a single override; use `reset-category` to revert an entire group at once.
### GET /api/settings
Return every setting in the registry with its default value, current override (if any), category, type, and (for secret settings) a masked representation. Category metadata accompanies the response so a UI can render labels and grouping.
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/settings", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/settings`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/settings' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PUT /api/settings
Bulk-update overrides. The body is `{ "settings": { key: value, ... } }`. Each value is validated against its registry definition; on any failure the whole request is rejected with per-key errors and nothing is written.
For secret settings, sending back the mask placeholder unchanged is silently skipped (so a UI can round-trip the full settings object without leaking or clobbering secrets).
```json Request theme={null}
{
"settings": {
"EXTRACTION_DEFAULT_METHOD": "mistral",
"COPILOT_TEMPERATURE": "0.5"
}
}
```
```python Python theme={null}
requests.put(f"{BASE_URL}/api/settings", headers=headers, json={"settings": {"EXTRACTION_DEFAULT_METHOD": "mistral"}})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/settings`, { method: "PUT", headers, body: JSON.stringify({ settings: { EXTRACTION_DEFAULT_METHOD: "mistral" } }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/settings' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"settings": {"EXTRACTION_DEFAULT_METHOD": "mistral"}}'
```
### DELETE /api/settings/
Remove a single override, reverting that setting to its registry default. Returns the key and the default value (empty string for secret-typed settings).
A registry key. Unknown keys return 404.
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/settings/EXTRACTION_DEFAULT_METHOD", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/settings/EXTRACTION_DEFAULT_METHOD`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/settings/EXTRACTION_DEFAULT_METHOD' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/settings/reset-category
Remove every override in a category at once. Returns the list of keys that were reset.
One of the registry categories: `copilot`, `agents`, `tools`, `knowledge-indexing`, `knowledge-retrieval`, `compaction`, `sources`. Unknown categories return 400.
```json Request theme={null}
{ "category": "copilot" }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/settings/reset-category", headers=headers, json={"category": "copilot"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/settings/reset-category`, { method: "POST", headers, body: JSON.stringify({ category: "copilot" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/settings/reset-category' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"category": "copilot"}'
```
## Registry keys reference
A high-level map of what's in the registry. The authoritative source for current defaults, types, and validation rules is `GET /api/settings` against your project. These tables tell you what each key controls so you can find the right knob without round-tripping.
### copilot (8 keys)
| Key | Controls |
| ------------------------ | ------------------------------------------------------------------------------------- |
| `copilot_model` | LLM used by the workflow copilot (one of `COPILOT_MODEL_OPTIONS`). Default `gpt-5.2`. |
| `COPILOT_TEMPERATURE` | Sampling temperature for the copilot ReAct loop. Default `0.7`. |
| `COPILOT_MAX_STEPS` | Max ReAct iterations before the copilot is forced to respond. Default `25`. |
| `MAX_BLOCK_NAME_LEN` | Block display names truncated to this length before injection into the prompt. |
| `MAX_CONFIG_VALUE_LEN` | Single config-value truncation cap (e.g., a large system prompt). |
| `MAX_TOTAL_STATE_LEN` | Hard cap on the total serialized workflow state passed to the copilot. |
| `MAX_CONFIG_DEPTH` | Recursion depth limit when truncating nested config dicts. |
| `SYSTEM_PROMPT_TRUNCATE` | Char limit when the copilot fetches an agent's system prompt via `get_asset_details`. |
### agents (3 keys)
| Key | Controls |
| ---------------------------- | ------------------------------------------------------------------------ |
| `AGENT_DEFAULT_MODEL` | Model used when an agent row has no model set. |
| `DEFAULT_MAX_CONTEXT_TOKENS` | Context token budget for RAG retrievals when not overridden per request. |
| `DELEGATE_MAX_STEPS` | ReAct step cap for sub-agent (delegate tool) runs. |
### tools (12 keys)
| Key | Controls |
| -------------------------- | ------------------------------------------------------------------------- |
| `CUSTOM_TOOL_TIMEOUT` | HTTP timeout for Custom Tool calls. Default `30`. |
| `MCP_TOOL_TIMEOUT` | Timeout for MCP `tools/call` requests. Default `30`. |
| `MAX_TOOL_OUTPUT_LENGTH` | Custom Tool response char cap. Default `10000`. |
| `DEFAULT_MAX_RESULT_CHARS` | Outer truncation cap applied after tool execution. Default `50000`. |
| `EXA_API_KEY` | Required for `web_search` builtin. Secret. |
| `FIRECRAWL_API_KEY` | Required for `web_scrape` builtin. Secret. |
| `FIRECRAWL_API_BASE` | Override the Firecrawl endpoint (self-hosted Firecrawl). |
| `VISION_MODEL` | Model used for `web_scrape include_images: true`. Default `gpt-4.1-mini`. |
| `WEB_SCRAPE_MAX_CHARS` | Per-page char cap for `web_scrape`. Default `200000`. |
| `WEB_SCRAPE_MAX_IMAGES` | Image-analysis count limit per scrape. |
| `VISION_TIMEOUT` | Per-image vision call timeout. |
| `VISION_MAX_WORKERS` | Concurrent vision-call worker pool. |
### knowledge-indexing (41 keys)
Covers chunking, embedding model selection, PageIndex tree-search params, GraphIndex enrichment, Doc2JSON extraction, full-document strategy, and BM25 indexing. Key families:
* **Chunk-embed strategy**: `CHUNK_EMBED_DEFAULT_CHUNK_SIZE`, `CHUNK_EMBED_DEFAULT_OVERLAP`, `CHUNK_EMBED_EMBEDDING_MODEL`
* **PageIndex strategy** (16 keys): `PAGEINDEX_INDEXING_MODEL`, `PAGEINDEX_LLM_MAX_CONCURRENT`, `PAGEINDEX_TOC_*`, `PAGEINDEX_MAX_*`, `PAGEINDEX_MIN_*`, `PAGEINDEX_SUMMARY_TOKEN_THRESHOLD`, `PAGEINDEX_DOC_DESCRIPTION_MAX_TOKENS`
* **GraphIndex strategy** (8 keys): `GRAPHINDEX_INDEXING_MODEL`, `GRAPHINDEX_ENRICHMENT_MODEL`, `GRAPHINDEX_EMBEDDING_MODEL`, `GRAPHINDEX_ENRICHMENT_MAX_*`
* **Full-document strategy** (4 keys): `FULLDOC_SUMMARY_MODEL`, `FULLDOC_EMBEDDING_MODEL`, `FULLDOC_SUMMARY_INPUT_CHARS`, `FULLDOC_SUMMARY_MAX_TOKENS`
* **Doc2JSON strategy** (8 keys): `DOC2JSON_*` for window size, overlap, extraction model, image use, retries
* **Cross-cutting**: `EXTRACTION_DEFAULT_METHOD` (`mistral`/`paddleocr`/`lighton`/`opendataloader`/`fitz`/`pdfplumber`/`auto`), `EMBEDDING_MAX_TOKENS_PER_BATCH`, `BM25_AUTO_INDEXING`
### knowledge-retrieval (19 keys)
| Key | Controls |
| -------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `KB_DEFAULT_TOP_K` | Default `top_k` for KB search when not specified per request. |
| `KB_DEFAULT_MAX_CONTEXT_TOKENS` | Default context token budget for retrieval. |
| `DEFAULT_IMAGE_DELIVERY` | How images are returned in multimodal retrieval. |
| `HYBRID_DEFAULT_VECTOR_WEIGHT` | Vector vs. sparse weight in hybrid search. |
| `RERANKER_DEFAULT_MODEL` | Default reranker (Cohere/Jina/Voyage/ZeroEntropy). |
| `RERANKER_CANDIDATE_COUNT` | Number of candidates fetched before reranking. |
| `QUERY_ENRICHMENT_DEFAULT_MODEL` | Model used to rewrite/expand user queries before retrieval. |
| `QUERY_ENRICHMENT_TEMPERATURE` | Sampling temperature for query enrichment. |
| `METADATA_ENRICHMENT_*` (7 keys) | LLM model, max tokens, concurrency, batch size, retry, image, char limits for chunk metadata enrichment. |
| `PAGEINDEX_RETRIEVAL_MODEL` | Retrieval-time model for PageIndex tree search. |
| `MAX_SEARCH_WORKERS` | Concurrent retrieval worker pool. |
| `DROPPED_ITEM_TEXT_LIMIT` | Char limit when logging dropped retrieval items for debugging. |
### compaction (5 keys)
| Key | Controls |
| ------------------------------ | ---------------------------------------------------------------------------- |
| `DEFAULT_COMPACTION_MODEL` | LLM used to summarize older session messages when context grows past budget. |
| `COMPACTION_KEEP_LAST_N` | Number of most recent messages preserved verbatim. |
| `CHARS_PER_TOKEN` | Heuristic char→token ratio used in budget calculations. |
| `COMPACTION_MAX_OUTPUT_TOKENS` | Output cap for the compaction summary. |
| `COMPACTION_BUFFER` | Token buffer kept free after compaction. |
### sources (4 keys)
| Key | Controls |
| -------------------------------- | ---------------------------------------------------------------------- |
| `URL_IMPORT_MAX_PAGES` | Hard cap on pages crawled when adding a URL source with crawl enabled. |
| `URL_IMPORT_MAX_IMAGES_PER_PAGE` | Per-page image-extraction cap. |
| `URL_IMPORT_CRAWL_MAX_DEPTH` | Max link-following depth for crawl. |
| `URL_IMPORT_IMAGE_MAX_SIZE_MB` | Per-image size limit before skip. |
There are 92 registered keys total. `GET /api/settings` is the authoritative live view.
## Error Responses
Settings routes return `{"error": ""}` (PUT also includes `details` with per-key errors). The codes below are the documentation labels for each failure mode.
| Status | Description |
| ------ | -------------------------------------------------------------------------------------------------------------- |
| 400 | One or more setting values failed registry validation; PUT response includes a `details` map of per-key errors |
| 400 | The given category is not in the registry (`reset-category`) |
| 400 | No settings provided (PUT body has empty `settings` object) |
| 404 | The given key is not in the registry (DELETE) |
# Sources
Source: https://docs.powabase.ai/api-reference/sources
Upload, manage, and extract content from documents and files.
Sources represent uploaded documents in the platform. Each source goes through an asynchronous extraction pipeline that converts files into structured derivatives (page texts, markdown, per-page images). Sources are the raw material for knowledge bases: once extracted, their content can be chunked and indexed for semantic search.
## Common Patterns
The typical flow: upload a file (POST /api/sources/upload), poll for completion (GET /api/sources/\{id} until extraction\_status is 'extracted' or 'attention\_required'), then retrieve extracted text (GET /api/sources/\{id}/page-texts). For files already in project storage, use import-from-storage. For web pages, use import-url. To swap extraction backends after the fact, POST /api/sources/\{id}/reextract with a new extraction\_model.
### GET /api/sources
List all sources with optional status filter.
Filter by extraction\_status. One of: pending, extracting, extracted, attention\_required, failed, cancelled.
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sources", headers=headers)
print(response.json())
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources`, { headers });
const sources = await res.json();
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sources' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/sources/upload
Upload a file for extraction. Accepts PDF, DOCX, PPTX, XLSX, images (PNG/JPG/WebP/GIF/TIFF), and plain text. Uses multipart/form-data. Optional fields: name (display name), metadata (JSON string, preserved through indexing), extraction\_model (PDF only; one of auto, mistral, paddleocr, lighton, opendataloader, fitz, pdfplumber).
```python Python theme={null}
with open("file.pdf", "rb") as f:
response = requests.post(
f"{BASE_URL}/api/sources/upload",
headers={"apikey": API_KEY, "Authorization": f"Bearer {API_KEY}"},
files={"file": ("file.pdf", f, "application/pdf")},
data={"extraction_model": "mistral"},
)
```
```typescript TypeScript theme={null}
const form = new FormData();
form.append("file", blob, "file.pdf");
form.append("extraction_model", "mistral");
const res = await fetch(`${BASE_URL}/api/sources/upload`, {
method: "POST",
headers: { apikey: API_KEY, Authorization: `Bearer ${API_KEY}` },
body: form,
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/sources/upload' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-F "file=@file.pdf" \
-F "extraction_model=mistral"
```
### POST /api/sources/import-from-storage
Import a file already in project storage as a source.
```json Request theme={null}
{
"bucket": "documents",
"path": "reports/q4.pdf",
"name": "Q4 Report"
}
```
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/sources/import-from-storage",
headers=headers,
json={"bucket": "documents", "path": "reports/q4.pdf"},
)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/import-from-storage`, {
method: "POST", headers,
body: JSON.stringify({ bucket: "documents", path: "reports/q4.pdf" }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/sources/import-from-storage' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"bucket": "documents", "path": "reports/q4.pdf"}'
```
### POST /api/sources/import-url
Import content from web URLs. mode='urls' imports a fixed list, mode='crawl' spiders from a seed URL, mode='sitemap' parses a sitemap XML. Requires Firecrawl API key to be configured in project settings.
```json Request theme={null}
{
"mode": "urls",
"urls": ["https://example.com/page1", "https://example.com/page2"],
"max_pages": 50
}
```
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/sources/import-url",
headers=headers,
json={"mode": "urls", "urls": ["https://example.com/page1"]},
)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/import-url`, {
method: "POST", headers,
body: JSON.stringify({ mode: "urls", urls: ["https://example.com/page1"] }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/sources/import-url' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"mode": "urls", "urls": ["https://example.com/page1"]}'
```
### GET /api/sources/
Get source details including extraction status.
Source ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sources/{source_id}", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sources/{id}' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/sources//page-texts
Get extracted text content organized by page.
Source ID
Specific page number
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sources/{source_id}/page-texts", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}/page-texts`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sources/{id}/page-texts' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PATCH /api/sources/
Update a source's display name or metadata.
Source ID
```json Request theme={null}
{
"name": "New Display Name",
"metadata": { "author": "alice" }
}
```
```python Python theme={null}
response = requests.patch(f"{BASE_URL}/api/sources/{source_id}", headers=headers, json={"name": "New Display Name"})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}`, { method: "PATCH", headers, body: JSON.stringify({ name: "New Display Name" }) });
```
```bash cURL theme={null}
curl -X PATCH '{BASE_URL}/api/sources/{id}' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "New Display Name"}'
```
### POST /api/sources//reextract
Re-run extraction on an existing source, optionally with a different extraction\_model.
Source ID
```json Request theme={null}
{
"extraction_model": "paddleocr"
}
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/sources/{source_id}/reextract", headers=headers, json={"extraction_model": "paddleocr"})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}/reextract`, { method: "POST", headers, body: JSON.stringify({ extraction_model: "paddleocr" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/sources/{id}/reextract' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"extraction_model": "paddleocr"}'
```
### POST /api/sources//cancel
Cancel an in-progress extraction. Sets extraction\_status to 'cancelled'.
Source ID
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/sources/{source_id}/cancel", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}/cancel`, { method: "POST", headers });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/sources/{id}/cancel' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/sources//download
Download the original uploaded file (as stored in project storage).
Source ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sources/{source_id}/download", headers=headers)
open("source.pdf", "wb").write(response.content)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}/download`, { headers });
const blob = await res.blob();
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sources/{id}/download' -o source.pdf \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/sources//derivatives//download
Download a derivative artifact. type is one of: markdown, text, page\_text, image. For per-page types (page\_text, image) pass index=N (0-based) in the query string.
Source ID
Derivative type: markdown, text, page\_text, or image
0-based index for per-page derivatives (page\_text, image)
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/sources/{source_id}/derivatives/markdown/download", headers=headers)
print(response.text)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}/derivatives/markdown/download`, { headers });
const text = await res.text();
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sources/{id}/derivatives/markdown/download' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### DELETE /api/sources/
Delete a source and its associated storage files (original + derivatives).
Source ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/sources/{source_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/sources/${sourceId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/sources/{id}' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Error Responses
Source routes return `{"error": ""}`.
| Status | Description |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | Upload missing the `file` form field, no filename, or invalid `metadata` JSON |
| 400 | Upload or `/import-from-storage`: unsupported file extension (allowed: `.pdf`, `.txt`, `.md`, `.docx`, `.xlsx`, `.xls`, `.pptx`, `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.tiff`) |
| 400 | Upload, `/import-from-storage`, or `/reextract`: invalid `extraction_model` (must be one of the configured PDF extraction methods) |
| 400 | `/import-from-storage`: missing `bucket` or `path` |
| 400 | `/import-url`: missing/invalid `mode` (`urls`/`crawl`/`sitemap`), missing or empty URL list, invalid sitemap URL, or no URLs found in sitemap |
| 400 | PATCH: no body or no valid fields to update |
| 400 | `/page-texts`: invalid `page` query (must be an integer ≥ 1); `/derivatives/{type}/download`: invalid `index` query |
| 404 | No source exists with the given ID; referenced file not found in storage (`/import-from-storage`); requested page or derivative does not exist; no file/derivative available for download |
| 409 | `/cancel`: extraction is not in a cancellable state (must be `pending` or `extracting`) |
| 500 | Upload, import, extraction, page download, or derivative download failed; body contains the underlying error message |
# Storage
Source: https://docs.powabase.ai/api-reference/storage
End-user-facing Storage endpoints at /storage/v1/* — bucket CRUD, object upload/download/list/copy/move, signed URLs, image transformations, and TUS resumable uploads.
The Storage API is Supabase Storage v1.33.0 mounted at `/storage/v1/*` on your project URL. It manages files in buckets backed by S3, with object metadata mirrored into the `storage.buckets` and `storage.objects` tables in Postgres so RLS can gate access.
For the conceptual model, see [Storage model](/concepts/storage-model). For uploading, see [Storage uploads](/guides/storage-uploads). For RLS, see [Storage policies](/guides/storage-policies).
## Common headers
```
apikey:
Authorization: Bearer
```
The Anon Key combined with a user's access token in `Authorization` is the pattern for browser-direct uploads. Service Role in both is for server-side admin operations.
The Storage API uses bearer auth for most endpoints. Public-URL reads (`/object/public/*`) work without auth; signed-URL reads pass auth via a `?token=` query parameter.
## Buckets
A bucket is a top-level container with a name, public flag, optional MIME allowlist, and optional file-size override.
### POST /storage/v1/bucket
Create a new bucket.
Bucket id (used in URLs). Must be unique. Lowercase alphanumeric, dashes, and dots.
Human-readable name. Defaults to `id`.
When `true`, the bucket's public URL (`/object/public/{bucket}/{path}`) returns files without auth. Default `false`.
Per-bucket override for the project's default file size limit. In bytes.
Restrict uploads to specific Content-Types. Default null = anything.
```json Request theme={null}
{
"id": "avatars",
"public": true,
"allowed_mime_types": ["image/png", "image/jpeg", "image/webp"],
"file_size_limit": 5242880
}
```
```python Python theme={null}
requests.post(
f"{BASE_URL}/storage/v1/bucket",
headers={"apikey": SERVICE_ROLE_KEY, "Authorization": f"Bearer {SERVICE_ROLE_KEY}", "Content-Type": "application/json"},
json={"id": "avatars", "public": True, "allowed_mime_types": ["image/png", "image/jpeg"]},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/storage/v1/bucket`, {
method: "POST",
headers: { apikey: SERVICE_ROLE_KEY, Authorization: `Bearer ${SERVICE_ROLE_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ id: "avatars", public: true, allowed_mime_types: ["image/png", "image/jpeg"] }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/storage/v1/bucket' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type: application/json" \
-d '{"id": "avatars", "public": true, "allowed_mime_types": ["image/png"]}'
```
**Response:** `{ "name": "avatars" }` on 200.
### GET /storage/v1/bucket
List all buckets. Returns the bucket metadata rows from `storage.buckets`, filtered by SELECT policies.
```python Python theme={null}
requests.get(f"{BASE_URL}/storage/v1/bucket", headers={"apikey": ANON_KEY, "Authorization": f"Bearer {ANON_KEY}"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/storage/v1/bucket`, { headers: { apikey: ANON_KEY, Authorization: `Bearer ${ANON_KEY}` } });
```
```bash cURL theme={null}
curl '{BASE_URL}/storage/v1/bucket' -H "apikey: " -H "Authorization: Bearer "
```
**Response:** array of `{ id, name, public, owner, created_at, updated_at, file_size_limit, allowed_mime_types }`.
### GET /storage/v1/bucket/
Get a specific bucket's metadata.
### PUT /storage/v1/bucket/
Update bucket metadata. Body fields are the same as create (`public`, `file_size_limit`, `allowed_mime_types`).
### DELETE /storage/v1/bucket/
Delete an empty bucket. Returns `409 not_empty` if the bucket contains any objects; empty it first via `POST /bucket/{id}/empty` or DELETE the objects individually.
### POST /storage/v1/bucket//empty
Delete all objects in a bucket without removing the bucket itself. Returns `200 OK`.
## Objects
The core file operations. Object endpoints are routed by bucket + path.
### POST /storage/v1/object//
Upload a new file. The path can include slashes; they're stored verbatim, not interpreted as folders by Storage. Returns `409 Duplicate` if a file already exists at that path; use `PUT` or set the `x-upsert: true` header to overwrite.
**Headers:**
* `Content-Type`: the MIME type of the file. Checked against the bucket's `allowed_mime_types` if set.
* `x-upsert: true` (optional): overwrite if exists.
**Body:** raw file bytes.
```python Python theme={null}
with open("avatar.png", "rb") as f:
requests.post(
f"{BASE_URL}/storage/v1/object/avatars/{user_id}/avatar.png",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {access_token}", "Content-Type": "image/png"},
data=f,
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/storage/v1/object/avatars/${userId}/avatar.png`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${accessToken}`, "Content-Type": "image/png" },
body: fileBlob,
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/storage/v1/object/avatars/user-123/avatar.png' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type: image/png" \
--data-binary @avatar.png
```
**Response:** `{ "Id": "...", "Key": "avatars/user-123/avatar.png" }`.
### PUT /storage/v1/object//
Overwrite an existing file at the same path. Same body shape as POST. Returns `200 OK`.
### GET /storage/v1/object/public//
Fetch a file from a public bucket. **No auth required.** Returns the file bytes with the stored `Content-Type`. Returns 400 if the bucket is not public.
### GET /storage/v1/object/authenticated//
Fetch a file from any bucket. Requires `Authorization: Bearer `. RLS on `storage.objects` decides whether the request succeeds.
```python Python theme={null}
response = requests.get(
f"{BASE_URL}/storage/v1/object/authenticated/documents/{path}",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {access_token}"},
)
file_bytes = response.content
```
```typescript TypeScript theme={null}
const res = await fetch(
`${BASE_URL}/storage/v1/object/authenticated/documents/${path}`,
{ headers: { apikey: ANON_KEY, Authorization: `Bearer ${accessToken}` } },
);
const blob = await res.blob();
```
```bash cURL theme={null}
curl '{BASE_URL}/storage/v1/object/authenticated/documents/report.pdf' \
-H "apikey: " -H "Authorization: Bearer " \
-o report.pdf
```
### DELETE /storage/v1/object//
Delete a single file. Requires DELETE policy match on `storage.objects` for the calling role.
### POST /storage/v1/object/list/
List files in a bucket. Filtered by SELECT policies on `storage.objects`.
Only return files whose `name` starts with this prefix.
Max files to return. Default 100.
Pagination offset.
`{ column: "name" | "created_at" | "updated_at", order: "asc" | "desc" }`.
Substring search on `name`. Case-insensitive.
```json Request theme={null}
{
"prefix": "user-123/",
"limit": 50,
"sortBy": { "column": "created_at", "order": "desc" }
}
```
```python Python theme={null}
requests.post(
f"{BASE_URL}/storage/v1/object/list/avatars",
headers={"apikey": ANON_KEY, "Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
json={"prefix": f"{user_id}/", "limit": 50},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/storage/v1/object/list/avatars`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ prefix: `${userId}/`, limit: 50 }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/storage/v1/object/list/avatars' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type: application/json" \
-d '{"prefix": "user-123/", "limit": 50}'
```
**Response:** array of `{ id, name, bucket_id, owner, created_at, updated_at, last_accessed_at, metadata }`.
### POST /storage/v1/object/copy
Copy a file to a new location (potentially in a different bucket).
Source bucket.
Source path.
Destination bucket. Defaults to source bucket.
Destination path.
### POST /storage/v1/object/move
Move a file to a new location. Same parameters as copy; the source is deleted on success.
## Signed URLs
For sharing files via TTL-bounded URLs that work without auth.
### POST /storage/v1/object/sign//
Mint a download signed URL.
URL TTL in seconds. Default 60. Max 604800 (7 days).
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/storage/v1/object/sign/documents/{path}",
headers={"apikey": SERVICE_ROLE_KEY, "Authorization": f"Bearer {SERVICE_ROLE_KEY}", "Content-Type": "application/json"},
json={"expiresIn": 3600},
)
signed_url = f"{BASE_URL}{response.json()['signedURL']}"
```
```typescript TypeScript theme={null}
const res = await fetch(
`${BASE_URL}/storage/v1/object/sign/documents/${path}`,
{
method: "POST",
headers: { apikey: SERVICE_ROLE_KEY, Authorization: `Bearer ${SERVICE_ROLE_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ expiresIn: 3600 }),
},
);
const { signedURL } = await res.json();
const downloadUrl = `${BASE_URL}${signedURL}`;
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/storage/v1/object/sign/documents/report.pdf' \
-H "apikey: " -H "Authorization: Bearer " -H "Content-Type: application/json" \
-d '{"expiresIn": 3600}'
```
**Response:** `{ "signedURL": "/object/sign/documents/report.pdf?token=..." }`. The URL is path-relative; prepend your `BASE_URL` to get the full URL.
### GET /storage/v1/object/sign//?token=...
Fetch a file via a signed URL. The token is the signature; no other auth required.
### POST /storage/v1/object/upload/sign//
Mint an upload signed URL: your server creates the URL, the client PUTs the file directly to it. Useful for high-bandwidth flows or offline clients.
URL TTL in seconds. Default 7200 (2 hours).
**Response:** `{ "url": "/object/upload/sign/documents/report.pdf?token=..." }`. The client then `PUT`s to `{url}` with the file bytes and `Content-Type` header.
### POST /storage/v1/object/sign
Sign multiple URLs in one request. Body: `{ "paths": ["bucket1/path1", "bucket2/path2"], "expiresIn": 3600 }`. Returns an array of signed URLs.
## Image transformations
The `render/image` endpoints route through imgproxy. The path shape mirrors the object endpoints (`public`, `authenticated`, `sign`) with the same auth requirements.
### GET /storage/v1/render/image/public//
Fetch a transformed image from a public bucket. Pass transformations as query parameters.
Output width in pixels.
Output height in pixels.
`cover`, `contain`, or `fill`. Default `cover`.
JPEG/WebP quality, 0-100. Default 80.
`webp`, `png`, `jpeg`, or `origin`. Default `origin` (preserves the original format).
```typescript TypeScript theme={null}
// Direct URL — no fetch wrapper needed
const thumbnailUrl =
`${BASE_URL}/storage/v1/render/image/public/avatars/${userId}/photo.png` +
`?width=200&height=200&resize=cover&quality=80`;
// Use directly in
```
```bash cURL theme={null}
curl '{BASE_URL}/storage/v1/render/image/public/avatars/user-123/photo.png?width=200&height=200' \
-o thumb.png
```
### GET /storage/v1/render/image/authenticated//
Same as above but auth-gated.
### GET /storage/v1/render/image/sign//?token=...
Same as above via signed URL.
To mint a signed transformation URL, hit `POST /storage/v1/object/sign/{bucket}/{path}` with a `transform` parameter in the body:
```json theme={null}
{
"expiresIn": 3600,
"transform": { "width": 200, "height": 200 }
}
```
Returns a signed URL that already includes the transformation parameters.
## TUS resumable uploads
For files larger than 50MB (the per-request limit). Implements the [TUS 1.0 protocol](https://tus.io/protocols/resumable-upload). Use a TUS client library rather than hand-rolling.
### POST /storage/v1/upload/resumable
Initiate a resumable upload. Subsequent `PATCH` requests with `Upload-Offset` headers stream the chunks. `HEAD` requests query progress.
**Required headers:**
* `apikey`
* `Authorization: Bearer `
* `Upload-Length: `
* `Upload-Metadata: bucketName ,objectName ,contentType `
* `Tus-Resumable: 1.0.0`
The TUS protocol is multi-step; see [Storage uploads](/guides/storage-uploads#tus-resumable-uploads-for-files-larger-than-50mb) for a worked example with `tus-js-client`.
## Error responses
Storage returns errors as `{"statusCode": "", "error": "", "message": ""}`. Common cases:
| Status | Error | When |
| ------ | ------------------- | ------------------------------------------------------------------- |
| 400 | `invalid_mime_type` | `Content-Type` not in bucket's `allowed_mime_types` |
| 400 | `invalid_signature` | Signed URL token doesn't validate (tampered, expired, or wrong key) |
| 401 | `Invalid JWT` | Missing or expired access token on an authenticated endpoint |
| 403 | `not_authorized` | RLS denied the operation |
| 404 | `not_found` | Bucket or object doesn't exist |
| 409 | `Duplicate` | POSTing to an existing path (use PUT or `x-upsert: true`) |
| 409 | `not_empty` | DELETE bucket with objects still in it |
| 413 | `Payload too large` | File exceeds the 50MB per-request limit (or bucket override) |
## Next steps
The four upload flows with worked examples.
RLS patterns on storage.objects.
Buckets, public vs private, the underlying tables.
The auth surface that mints the tokens you'll use here.
# Tools
Source: https://docs.powabase.ai/api-reference/tools
Manage custom tools and view builtin tools available to agents.
Tools extend agent capabilities beyond conversation. The platform provides builtin tools (database\_query, database\_write, http\_request, code\_execute, storage\_read, storage\_write, web\_search, web\_scrape) and lets you create custom tools that call your own endpoints. A custom tool is defined with a name, description, JSON Schema for inputs, and an endpoint URL that the platform calls when the agent uses the tool.
## Common Patterns
List available tools with GET /api/tools to see both builtin and custom tools. Create custom tools with a clear description and input schema; agents use the description to decide when to call the tool. Assign tools to agents via the agent tools API (POST /api/agents/\{id}/tools).
### GET /api/tools
List all tools (builtin: database\_query, database\_write, http\_request, code\_execute, storage\_read, storage\_write, web\_search, web\_scrape + custom).
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/tools", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/tools`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/tools' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/tools
Create a custom tool. Required: `name`, `description`, `type`, `input_schema`. The route also accepts an optional `config` object. For HTTP-callable tools, the runtime reads `config.endpoint`, `config.method` (default `POST`), `config.headers`, and `config.timeout_seconds` when an agent invokes the tool.
Display name; agents see this when deciding to call the tool.
Free-text description; the LLM uses it to decide when to call the tool.
Free-form tag (≤50 chars). Stored verbatim; not used by runtime dispatch. Pick something descriptive (e.g. `http`).
JSON Schema for the tool's arguments. Not validated at create-time; passed straight through.
Tool-specific config. For custom HTTP tools, set `{ endpoint, method?, headers?, timeout_seconds? }` here; that's where the runtime reads them.
Top-level `endpoint_url` / `method` / `headers` are silently dropped at create-time; only `name`, `description`, `type`, `input_schema`, and `config` are persisted. `type` is stored as a free-form `String(50)`; the runtime does NOT switch on it. Agent-tool dispatch uses the assignment's own `tool_type` (`builtin` vs `custom`), not the Tool's `type` field.
```json Request theme={null}
{
"name": "weather_lookup",
"description": "Get current weather for a city",
"type": "http",
"input_schema": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
},
"config": {
"endpoint": "https://api.weather.com/v1/current",
"method": "GET"
}
}
```
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/tools", headers=headers, json={
"name": "weather_lookup",
"description": "Get current weather",
"type": "http",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
"config": {"endpoint": "https://api.weather.com/v1/current", "method": "GET"},
})
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/tools`, { method: "POST", headers, body: JSON.stringify({
name: "weather_lookup",
description: "Get current weather",
type: "http",
input_schema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
config: { endpoint: "https://api.weather.com/v1/current", method: "GET" },
}) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/tools' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"name": "weather_lookup", "description": "...", "type": "http", "input_schema": {"type": "object"}, "config": {"endpoint": "https://api.weather.com/v1/current"}}'
```
### GET /api/tools/
Get a tool definition by ID.
Tool ID
```python Python theme={null}
response = requests.get(f"{BASE_URL}/api/tools/{tool_id}", headers=headers)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/tools/${toolId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/tools/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PUT /api/tools/
Update a custom tool.
Tool ID
```python Python theme={null}
response = requests.put(f"{BASE_URL}/api/tools/{tool_id}", headers=headers, json={"description": "Updated"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/tools/${toolId}`, { method: "PUT", headers, body: JSON.stringify({ description: "Updated" }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/tools/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"description": "Updated"}'
```
### DELETE /api/tools/
Delete a custom tool.
Tool ID
```python Python theme={null}
response = requests.delete(f"{BASE_URL}/api/tools/{tool_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/tools/${toolId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/tools/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Error Responses
Tool routes return `{"error": ""}`.
| Status | Description |
| ------ | --------------------------------------------------------------------------------- |
| 400 | Missing required field (`name`, `description`, `type`, or `input_schema`) on POST |
| 404 | No tool exists with the given ID |
# Webhooks
Source: https://docs.powabase.ai/api-reference/webhooks
Trigger deployed workflows from external systems via signed HTTP calls.
Webhooks let external systems (Stripe, GitHub, form submissions, internal services) trigger a deployed workflow over HTTP. The webhook ID and secret are minted when you arm a workflow for webhook execution. See the [workflow guide](/guides/workflows-programmatic) for the full deploy + arm flow.
The trigger endpoint is **unauthenticated by design** (no `apikey` / `Authorization: Bearer {API_KEY}` headers needed). Authentication is per-webhook: the secret token returned by the arm step must be presented either in an `Authorization: Bearer ` header or as a `?token=` query parameter.
## Common Patterns
A workflow's webhook lives in one of two states:
* **Deployed**: the webhook is permanently active and accepts unlimited calls.
* **Armed for single use**: the webhook accepts exactly one call within the arm window; after firing, you must re-arm. This suits one-shot integrations (testing, manual triggers) where you don't want a long-lived endpoint.
The trigger endpoint behaves identically in both modes. The difference is only in how the workflow was prepared.
### POST /api/webhooks/
Trigger a workflow. The request body becomes the workflow's input variables. Returns the execution outcome synchronously (the workflow runs inline with a 5-minute timeout).
The webhook ID returned when the workflow was armed/deployed. Must be a valid UUID.
`Bearer ` — preferred over the query-param form.
Webhook secret. Use this when you can't set headers (e.g., a webhook source that only supports a URL).
Use one auth mechanism or the other (Bearer header OR `?token=` query). The server checks the header with `auth_header.lower().startswith("bearer ")` (note the trailing space). If your `Authorization` header is exactly `Bearer ` (trailing space, no token), which is what `Bearer ${secret || ""}` produces when `secret` is falsy, the server reads an empty token and returns 401 without consulting `?token=`. `Bearer` with no trailing space falls through to the query param.
```json Body theme={null}
{
"customer_email": "user@example.com",
"amount_cents": 4900
}
```
```json 200 — success theme={null}
{
"execution_id": "exec-uuid",
"status": "completed",
"output": { "summary": "..." }
}
```
```json 504 — timeout theme={null}
{
"error": "Execution timed out after 300s",
"code": "execution_timeout",
"execution_id": "exec-uuid"
}
```
```python Python theme={null}
# Header auth (recommended)
response = requests.post(
f"{BASE_URL}/api/webhooks/{webhook_id}",
headers={"Authorization": f"Bearer {webhook_secret}", "Content-Type": "application/json"},
json={"customer_email": "user@example.com"},
)
# Or query-param auth (when headers aren't an option)
response = requests.post(
f"{BASE_URL}/api/webhooks/{webhook_id}?token={webhook_secret}",
json={"customer_email": "user@example.com"},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/webhooks/${webhookId}`, {
method: "POST",
headers: { Authorization: `Bearer ${webhookSecret}`, "Content-Type": "application/json" },
body: JSON.stringify({ customer_email: "user@example.com" }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/webhooks/{webhook_id}' \
-H "Authorization: Bearer {webhook_secret}" \
-H "Content-Type: application/json" \
-d '{"customer_email": "user@example.com"}'
```
The trigger endpoint executes the workflow inline and returns the final output. For long-running workflows that exceed the 5-minute synchronous limit, design the workflow to acknowledge the request quickly and continue work asynchronously (e.g. dispatch via a `general_api` block to a background processor).
## Security model
What the trigger endpoint does and doesn't do. Webhook security is where unstated assumptions get exploited, so the guarantees are spelled out below.
**What it does:**
* **Verifies the secret via constant-time comparison.** `hmac.compare_digest` on the server side prevents timing-attack style secret guessing.
* **Validates the secret BEFORE the deploy-or-arm state gate.** An invalid secret cannot consume the single-use arm slot, so bad requests don't accidentally disarm a workflow before legitimate ones can fire.
* **Atomic single-use disarm.** For armed (single-use) workflows, the platform issues a `RETURNING id` UPDATE that disarms in the same statement. Only the first of N concurrent requests with a valid secret wins; the rest see `webhook_armed_until = NULL` and return `403`.
**What it does NOT do:**
* **No HMAC of the request body.** The webhook secret authenticates the *caller*, not the request. A man-in-the-middle who can read the URL or `Authorization` header can replay the request with any body. **For sensitive triggers, use TLS termination at your network boundary and a trusted client.** Don't rely on the webhook itself to prove the body was authored by the upstream system.
* **No timestamp / nonce / replay-window protection.** The secret doesn't expire and isn't tied to a specific request. An attacker who captures one valid request can replay it until you rotate the secret.
* **No retry / redelivery.** A failed trigger is gone; the trigger endpoint is fire-and-forget from the upstream's perspective. If you need at-least-once delivery semantics, configure your upstream system's own retry policy.
* **No request idempotency.** Two identical calls (same body, same time) trigger two executions. Pair with workflow logic that's idempotent if you need that.
**The 10-minute arm TTL is the closest thing to a replay window.** A captured request to an armed (vs deployed) webhook has at most 10 minutes from arming to be replayed. Deployed webhooks have no such window.
For upstream systems that support body signatures (Stripe, GitHub, etc.), do the signature check on your end inside the workflow's first block (a `code` block validating the signature, branching to `response` on failure). The platform won't do it for you.
## Error Responses
Errors return `{"error": "", "error_code": ""}`. The 401 responses additionally omit `error_code` (they're a plain `{"error": "Unauthorized"}`).
| Status | `error_code` | Description |
| ------ | -------------------- | ---------------------------------------------------------------------------------- |
| 400 | `VALIDATION_ERROR` | `webhook_id` is not a valid UUID |
| 401 | — | Missing or incorrect webhook secret |
| 403 | — | Webhook is not active (workflow not deployed and no live arm token) |
| 404 | `WORKFLOW_NOT_FOUND` | No workflow has a webhook block with this ID |
| 500 | `EXECUTION_FAILED` | The workflow ran but raised an error. The `execution_id` is included for debugging |
| 504 | `EXECUTION_TIMEOUT` | The workflow exceeded the 5-minute synchronous limit |
# Workflows
Source: https://docs.powabase.ai/api-reference/workflows
Create and manage automated block-based workflows with visual graph definitions.
Workflows are DAG-based automation pipelines that execute a fixed sequence of blocks (LLM calls, code execution, conditions, agent runs). Unlike agents that decide what to do dynamically, workflows follow a predetermined graph. They can be executed directly, streamed, or triggered externally via webhooks.
## Common Patterns
Create a workflow, define its graph with PUT /api/workflows/\{id}/graph, then execute. For external triggers, deploy the workflow and arm it to get a webhook URL. Webhooks are single-use, so re-arm after each trigger. Use the streaming endpoint for real-time block execution updates.
## CRUD
### GET /api/workflows
List workflows.
Max results
Pagination offset
```python Python theme={null}
requests.get(f"{BASE_URL}/api/workflows", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/workflows' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/workflows
Create a workflow.
```json Request theme={null}
{ "name": "My Workflow" }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/workflows", headers=headers, json={"name": "My Workflow"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows`, { method: "POST", headers, body: JSON.stringify({ name: "My Workflow" }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"name": "My Workflow"}'
```
### GET /api/workflows/
Get workflow with blocks and edges.
Workflow ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/workflows/{wf_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/workflows/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### PATCH /api/workflows/
Update workflow metadata.
Workflow ID
```python Python theme={null}
requests.patch(f"{BASE_URL}/api/workflows/{wf_id}", headers=headers, json={"name": "Renamed"})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}`, { method: "PATCH", headers, body: JSON.stringify({ name: "Renamed" }) });
```
```bash cURL theme={null}
curl -X PATCH '{BASE_URL}/api/workflows/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"name": "Renamed"}'
```
### DELETE /api/workflows/
Delete a workflow.
Workflow ID
```python Python theme={null}
requests.delete(f"{BASE_URL}/api/workflows/{wf_id}", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}`, { method: "DELETE", headers });
```
```bash cURL theme={null}
curl -X DELETE '{BASE_URL}/api/workflows/{id}' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Graph
### PUT /api/workflows//graph
Save the complete graph: blocks and edges.
Workflow ID
```json Request theme={null}
{
"blocks": [
{ "id": "start", "type": "starter", "config": {}, "position": {"x": 0, "y": 0} },
{ "id": "out", "type": "response", "config": {}, "position": {"x": 300, "y": 0} }
],
"edges": [
{ "source": "start", "target": "out" }
]
}
```
Unknown block types are rejected with `400 Unknown block type`. Canonical types: `starter`, `webhook`, `agent`, `orchestration`, `code`, `condition`, `general_api`, `platform_api`, `response`, `split` (plus back-compat aliases `function` → code, `api_call` → general\_api).
```python Python theme={null}
requests.put(f"{BASE_URL}/api/workflows/{wf_id}/graph", headers=headers, json={"blocks": [...], "edges": [...]})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}/graph`, { method: "PUT", headers, body: JSON.stringify({ blocks: [...], edges: [...] }) });
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/workflows/{id}/graph' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"blocks": [], "edges": []}'
```
## Deploy
### POST /api/workflows//deploy
Deploy the workflow (enables webhook triggering).
Workflow ID
```python Python theme={null}
requests.post(f"{BASE_URL}/api/workflows/{wf_id}/deploy", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}/deploy`, { method: "POST", headers });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows/{id}/deploy' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/workflows//undeploy
Undeploy the workflow.
Workflow ID
```python Python theme={null}
requests.post(f"{BASE_URL}/api/workflows/{wf_id}/undeploy", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}/undeploy`, { method: "POST", headers });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows/{id}/undeploy' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### POST /api/workflows//arm
Arm the workflow's webhook for a single external trigger. Opens a 10-minute window during which the webhook accepts exactly one call; after it fires or expires, re-arm.
Workflow ID
The response is `{"ok": true, "armed_until": ""}`, **not** a webhook id/secret. The `webhook_id` and `webhook_secret` live on the webhook block's config in the saved graph; fetch them with `GET /api/workflows/{id}` and read from the block whose `type == "webhook"`.
```python Python theme={null}
response = requests.post(f"{BASE_URL}/api/workflows/{wf_id}/arm", headers=headers)
print(response.json()) # {"ok": true, "armed_until": "2026-01-01T00:00:00Z"}
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/workflows/${wfId}/arm`, { method: "POST", headers });
const { ok, armed_until } = await res.json();
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows/{id}/arm' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Execution
### POST /api/workflows//execute
Execute the workflow synchronously.
Workflow ID
Map of input variable names → values. Defaults to `{}` when omitted.
`variables` is the canonical key. The route also accepts `input` as a legacy alias (`data.get("variables", data.get("input", {}))`); prefer `variables` in new code.
```json Request theme={null}
{ "variables": { "text": "..." } }
```
```python Python theme={null}
requests.post(f"{BASE_URL}/api/workflows/{wf_id}/execute", headers=headers, json={"variables": {"text": "..."}})
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}/execute`, { method: "POST", headers, body: JSON.stringify({ variables: { text: "..." } }) });
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows/{id}/execute' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"variables": {"text": "..."}}'
```
### POST /api/workflows//execute/stream
Execute with streaming SSE. Same body shape as `/execute`: pass `variables` (canonical) or `input` (legacy alias).
Workflow ID
Map of input variable names → values. Defaults to `{}` when omitted.
```python Python theme={null}
requests.post(f"{BASE_URL}/api/workflows/{wf_id}/execute/stream", headers=headers, json={"variables": {"text": "..."}}, stream=True)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}/execute/stream`, { method: "POST", headers, body: JSON.stringify({ variables: { text: "..." } }) });
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/workflows/{id}/execute/stream' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" -H "Content-Type: application/json" -d '{"variables": {"text": "..."}}'
```
### GET /api/workflows//executions
List execution history.
Workflow ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/workflows/{wf_id}/executions", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}/executions`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/workflows/{id}/executions' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
### GET /api/workflows//executions//logs
Get per-block execution logs.
Workflow ID
Execution ID
```python Python theme={null}
requests.get(f"{BASE_URL}/api/workflows/{wf_id}/executions/{exec_id}/logs", headers=headers)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/workflows/${wfId}/executions/${execId}/logs`, { headers });
```
```bash cURL theme={null}
curl '{BASE_URL}/api/workflows/{id}/executions/{eid}/logs' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
```
## Error Responses
Workflow routes return one of two body shapes:
* Plain `{"error": ""}`: list/create/patch/delete and the deploy/undeploy/arm endpoints.
* Structured `{"error": "", "error_code": ""}` (via the shared `error_response` helper): `GET /workflows/{id}`, `PUT /workflows/{id}/graph`, and the synchronous `/execute` endpoint.
Streaming endpoints (`/execute/stream`) return HTTP 200 and emit errors as SSE `data: {"type": "error", ...}` events; only the timeout event includes `error_code` (`EXECUTION_TIMEOUT`).
| Status | `error_code` | Description |
| ------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | — | Missing required field (e.g. `name`, no fields to update, no JSON body) |
| 400 | `VALIDATION_ERROR` | Graph save: each block needs `id`/`type`, each edge needs `source`/`target`, block types must be in the engine registry, and edges must reference blocks in the same graph |
| 404 | `WORKFLOW_NOT_FOUND` | No workflow exists with the given ID (`GET /workflows/{id}`, `PUT /graph`, `POST /execute`) |
| 404 | — | No workflow or execution exists with the given ID (other endpoints) |
| 504 | `EXECUTION_TIMEOUT` | Synchronous `/execute` exceeded its timeout |
| 500 | `EXECUTION_FAILED` | Synchronous `/execute` raised an error |
# Agents & Tools
Source: https://docs.powabase.ai/concepts/agents-tools
Agents are LLM-powered conversational entities that use a ReAct loop to reason, call tools, and respond. They support eight builtin tools, custom HTTP tools, MCP server integration, session-based memory, hook-based middleware, and a human-in-the-loop approval flow.
## What is an Agent?
An Agent wraps an LLM with a system prompt, temperature, a set of tools, and optional knowledge bases. When you send a message, the agent enters a ReAct (Reason + Act) loop: the LLM decides whether it needs to call a tool, executes the tool if needed, observes the result, and either calls another tool or generates a final response. This loop continues for up to 25 steps (configurable per run). On the final step, tools are withheld so the LLM is forced to produce a text response.
## The ReAct Loop
Each iteration of the loop is a step. Within a step, the agent can call multiple tools: concurrency-safe tools (like knowledge base search) run in parallel via a thread pool, while other tools execute sequentially. If the agent makes the same tool call with identical arguments three times in a row, the platform detects a doom loop and terminates the run. During streaming, each step is visible as SSE events: step\_started, tool\_call, tool\_result, step\_completed, and chunk events for the final response.
### Context Management
The agent automatically manages its context window. Before each LLM call, it estimates the token count and, if nearing the model's context limit, triggers proactive compaction: first pruning old tool results (replacing them with placeholders while keeping the last 3 user turns), then if still over the limit, summarizing the older conversation using a lightweight LLM (gpt-4.1-nano). If the LLM returns a prompt\_too\_long error, the agent retries with compaction. If the LLM output is truncated, the agent injects a "continue where you left off" message and retries up to 3 times.
## Tool Types
Agents can use three categories of tools: builtin tools provided by the platform, custom tools that call your HTTP endpoints, and MCP server tools discovered at runtime.
### Builtin Tools
The platform provides eight builtin tools. Assign them to an agent by name via POST /api/agents/\{id}/tools. The database tools include schema-level access control: when assigning them, you configure which schemas and tables the agent can access.
| Tool | Description | Constraints |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| database\_query | Execute read-only SQL SELECT queries against the project database | Must start with SELECT. Multi-statement blocked (no semicolons). Results capped at 50,000 chars. Rollback after read. |
| database\_write | Execute INSERT, UPDATE, and DELETE operations | UPDATE requires a WHERE clause (no mass updates). DELETE requires a WHERE clause (no mass deletes). All identifiers validated against injection. |
| http\_request | Make HTTP requests to external APIs | Response capped at 10,000 chars. 30-second timeout. **No SSRF validation** — see warning below. |
| code\_execute | Run Python or JavaScript in a sandboxed environment | Delegated to external sandbox service. Default 30-second timeout, configurable per call. |
| storage\_read | List and download files from project storage buckets | Binary files return a signed URL instead of content. Supports list (with prefix/limit/offset) and download operations. |
| storage\_write | Upload text content to project storage buckets | UTF-8 text content only. Returns path and file size. |
| web\_search | Search the web using Exa.ai. Five `search_type` modes (`auto`, `neural`, `keyword`, plus the agentic `deep` and `deep-reasoning` tiers), domain filters, date ranges, category filter, and content depth control | Results capped at 20K-50K chars by content mode. 1-10 results. Requires EXA\_API\_KEY. `deep` / `deep-reasoning` are slower and bill at higher tiers — see below. |
| web\_scrape | Extract content from web pages as clean markdown, with optional AI vision image analysis | Results capped at 200K chars. Supports markdown/HTML/links formats. include\_images uses gpt-4.1-mini vision. Direct image URLs bypass Firecrawl. Requires FIRECRAWL\_API\_KEY. |
**`http_request` has no SSRF protection.** Unlike Custom Tools (below), the builtin `http_request` tool calls the URL the agent supplies directly, with no allow-list and no validation against internal addresses. An agent given `http_request` can reach RFC1918 ranges, `localhost`, and cloud metadata endpoints (e.g. `169.254.169.254`). Only enable `http_request` for agents whose model + prompt you trust, or use a Custom Tool with a fixed `endpoint` instead. Custom Tools enforce SSRF via `validate_url` on the endpoint URL.
#### Web search modes (`search_type`)
The `web_search` tool accepts a `search_type` argument that selects how Exa runs the query. The first three are standard single-pass searches; the last two run Exa's **agentic deep search**, which iterates over multiple queries and reads more pages for higher-quality results, at the cost of latency and credits.
| `search_type` | What it does | Relative cost |
| ------------------ | ------------------------------------------------------------------------------ | ------------- |
| `auto` *(default)* | Exa picks neural or keyword per query | Standard |
| `neural` | Semantic / meaning-based search | Standard |
| `keyword` | Exact term matching | Standard |
| `deep` | Agentic deep search — slower, broader, higher quality | Higher |
| `deep-reasoning` | Agentic deep search with an added reasoning pass — slowest and highest quality | Highest |
The other `web_search` arguments apply to every mode: `num_results` (1–10, default 5), `include_domains` / `exclude_domains`, `start_date` / `end_date` (ISO 8601 published-date bounds), `category` (`company`, `news`, `research paper`, `tweet`, `github`, `wikipedia`, `personal site`), and `content_mode` (`highlights` default, `compact_text`, or `full_text`).
**Billing follows the mode.** A standard search bills the `web_search` action; `deep` bills `web_search_deep` and `deep-reasoning` bills `web_search_deep_reasoning`, both priced higher than a standard search. The tier is resolved from the actual `search_type` sent to Exa, including when you pin `search_type` in an agent's tool config, so a deep search always bills the deep rate, never the standard one. Exact per-action prices live on the [pricing page](https://powabase.ai). If the search fails for a platform-side reason (5xx, 429, timeout, or a missing `EXA_API_KEY`), the call is **not** billed; only tenant-fault 4xx errors stay billed.
#### Tool prerequisites
Some builtin tools need an API key or sandbox configured before they'll work. If the prerequisite is missing, the tool returns a JSON error message to the agent (e.g. `"Exa API key not configured. Set it in Settings > Tools."`) rather than throwing.
| Tool | What it needs | Where to set it |
| ----------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `database_query` / `database_write` | — | No setup. Uses the project's superuser connection automatically. |
| `http_request` | — | No setup. |
| `code_execute` | A code sandbox service (`CODE_SANDBOX_URL`, optional `CODE_SANDBOX_API_KEY`) | Platform-level env var — set by the platform team for managed cloud; self-host operators configure their own sandbox. Without it the tool returns `"Code sandbox is not configured"`. |
| `storage_read` / `storage_write` | — | No setup. Uses the project's Storage service. |
| `web_search` | `EXA_API_KEY` setting | Studio → Settings → Tools, or `PUT /api/settings/EXA_API_KEY` |
| `web_scrape` | `FIRECRAWL_API_KEY` setting (and optional `VISION_MODEL` if `include_images: true`) | Studio → Settings → Tools, or `PUT /api/settings/FIRECRAWL_API_KEY`. `VISION_MODEL` defaults to `gpt-4.1-mini`. |
If you're enabling these via API rather than the Studio, set them with `PUT /api/settings/{key}`. See [Settings API](/api-reference/settings).
### Custom Tools
Custom tools call your own HTTP endpoints. You define the tool with a name, description, JSON Schema for inputs, an endpoint URL, HTTP method, and optional headers. When the agent decides to use the tool, the platform POSTs the tool arguments as JSON to your endpoint and returns the response to the agent. Responses are capped at 10,000 characters. Timeout is 30 seconds. SSRF validation prevents the agent from calling internal network addresses.
### MCP Servers
MCP (Model Context Protocol) servers let you connect agents to external tool providers. Add an MCP server URL to your agent, and at the start of each run the platform sends a `tools/list` JSON-RPC request to discover available tools. Discovered tools are namespaced (`mcp__{server_name}__{tool_name}`) and added to the agent's tool set alongside builtin and custom tools. Tool calls are executed via `tools/call` JSON-RPC requests. Timeout is 30 seconds for both discovery and per tool call.
**Transport:** the platform's MCP client speaks JSON-RPC over **HTTP POST** to the server URL. (The DB schema accepts a `transport` field with default `http`; other values like `sse` are stored but not consumed by the current client.) Use a server that exposes JSON-RPC over HTTP; most MCP server frameworks do this by default.
**Headers:** any `headers` you configure on the server (typically for auth) are sent on both `tools/list` and `tools/call` requests. There's no per-call header injection.
**enabled flag:** if you set `enabled: false` on an MCP server, discovery skips it entirely: its tools aren't added and no `tools/call` requests fire. Use this to temporarily disconnect a server without removing it.
**Failure handling:** if `tools/list` fails (network error, non-200, JSON-RPC error), the agent run **continues without that server's tools** (fail-open). The failure is logged but not surfaced as a run error.
**Tool annotations:** the platform reads `annotations.readOnlyHint`, `destructiveHint`, and `openWorldHint` from each discovered tool and uses them for concurrency / safety planning. A `readOnlyHint: true` tool may be called in parallel with other read-only tools.
### Knowledge Base Search Tool
When you link a knowledge base to an agent, the platform automatically creates a knowledge\_search tool. The agent can call this tool with a natural language query to search the KB using whatever retrieval strategy is configured (vector, hybrid, full-text, or tree search). If multiple KBs are linked, a single knowledge\_search tool is created with a knowledge\_base\_names filter parameter so the agent can target specific KBs. The search tool is concurrency-safe and read-only, so it runs in parallel with other safe tools.
## Sessions & Memory
Every agent conversation happens within a session. A session stores a sequence of runs, each containing the user input, assistant response, tool calls, tool results, and usage statistics. When you pass a session\_id with a new message, the platform loads all completed runs from that session and reconstructs the full message history for the LLM. Sessions persist until explicitly deleted, enabling long-running multi-turn conversations. New sessions are created automatically if no session\_id is provided; the start SSE event returns the generated session\_id for future use.
## Hooks & Middleware
Hooks let you intercept agent execution at **discrete lifecycle boundaries** with three types of middleware: HTTP webhooks, rule-based policies, and human approval gates. Each hook is configured with an `event` (when it fires), a `type` (what it does), an optional `matcher` (which tool to target by name), and a `config` object.
| Event | When it fires | Can block? | Can modify? |
| --------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | -------------------------------------------------- |
| `OnRunStart` | Before the run begins processing | Yes (fails the entire run) | No |
| `PreToolUse` | Before each tool execution | Yes (returns an error to the LLM) | Yes (`modified_input` replaces the tool arguments) |
| `OnDelegation` | Before a delegate-tool call, in multi-agent setups (fires after `PreToolUse`, only for delegate tools) | Yes (blocks the delegation) | No |
| `PostToolUse` | After each tool execution | No | Yes (`modified_output` replaces the tool result) |
| `PreResponse` | After the ReAct loop, before returning the final content | Yes (replaces the response with a blocked message) | Yes (`modified_output` replaces the final content) |
| `OnRunComplete` | After successful completion (fire-and-forget) | No | No |
| Hook type | Behavior |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `http` | POSTs the event payload to a webhook URL and applies the JSON response (see contract below). Fail-open on any error (non-200, timeout, or unreachable). Timeout defaults to 5s (`config.timeout_seconds`); custom `config.headers` are sent with the request, and the URL is SSRF-validated. |
| `rule` | Evaluates conditions locally against the tool arguments (or, for output events, `{"output": ...}`). Supports operators `CONTAINS`, `STARTS_WITH`, `MATCHES` (regex), and `IN`. First matching deny rule wins. |
| `approval` | Pauses execution and emits an `approval_requested` SSE event. Blocks until the approve endpoint is called or `config.timeout` (default 300s) expires. |
**Hooks are not stream middleware.** Every event fires at a lifecycle boundary; they do not see the token stream chunk-by-chunk. Even `PreResponse` runs *after* the ReAct loop completes, on the fully assembled content; by then a streaming client has already received every `chunk` event, so a hook cannot rewrite, inject, or intercept tokens mid-stream. If you need to transform streamed output (e.g. rewriting inline markers into rich content as it flows to the client), do it in your own application or proxy layer that sits between Powabase's SSE stream and your end users, not in a hook.
### HTTP hook contract
When an `http` hook fires, Powabase sends a `POST` to `config.url` with this body:
```json theme={null}
{
"event": "PreToolUse",
"tool_name": "database_query",
"data": { "query": "SELECT ..." },
"output": "..."
}
```
`tool_name` is `""` for non-tool events (`OnRunStart`, `PreResponse`, `OnRunComplete`). `data` carries the event's input: the tool arguments for tool events, `{"message": ""}` for `OnRunStart`, `{}` for `PreResponse`. `output` is present only when there is one (`PostToolUse` → the tool result, `PreResponse` → the assembled content).
Your endpoint must return `200` with a JSON body; any other status (or a timeout/error) is treated as **allow** (fail-open):
```json theme={null}
{
"action": "allow",
"message": "optional reason, surfaced when action is deny",
"modified_input": { "query": "SELECT ... LIMIT 100" },
"modified_output": "redacted result"
}
```
* `action` defaults to `"allow"`; return `"deny"` to block (only on blockable events; see the table).
* `modified_input` replaces the tool arguments (`PreToolUse` only).
* `modified_output` replaces the tool result (`PostToolUse`) or the final content (`PreResponse`).
### Hook record fields
A hook is created with `event` and `type` (both required) and a `config` object (required, may be `{}`). Optional: `matcher` (a tool name; omit to match all tools), `enabled` (default `true`), and `position` (an integer that orders hook execution, default `0`). Hooks are immutable once created: to change one, delete it and add a new one. See the [Agents API reference](/api-reference/agents#hooks) for the CRUD endpoints.
The API stores `event` and `type` exactly as sent and does **not** validate them against the lists above. A hook with an unrecognized event or type is created successfully but **never fires**, so a typo fails silently. Use the exact values documented here.
## Human-in-the-Loop Approval
The approval flow is implemented as a PreToolUse hook of type approval. When the agent tries to call a matching tool, execution pauses: the SSE stream emits an approval\_requested event with the tool name and arguments, then the thread blocks waiting for a decision. Your application calls POST /api/agents/runs/\{run\_id}/approve with \{approved: true} or \{approved: false}. On approval, the tool executes normally and the stream resumes. On rejection, the tool call is skipped and the agent receives a denial message so it can choose an alternative approach. If no decision arrives within 300 seconds (configurable), the tool call is blocked with a timeout error.
**Scope approval to specific tools**
Set the hook's matcher field to a specific tool name (e.g., "database\_write") to only require approval for that tool. Omit matcher to require approval for all tool calls.
## Streaming
The streaming endpoint (POST /api/agents/\{id}/run/stream) returns Server-Sent Events for the entire run lifecycle. When tools are present, the agent runs the full ReAct loop and events are emitted as they occur. When no tools are assigned, the agent streams LLM tokens directly as chunk events. If the client disconnects mid-stream, the platform detects the GeneratorExit, sets the abort signal to stop the agent, and spawns a background thread to persist the partial run.
## Sync (Non-Streaming) Run
The sync endpoint (POST /api/agents/\{id}/run) runs the agent without streaming and returns the full response as JSON. In this mode, no tools are loaded and no ReAct loop runs: the agent makes a single LLM call with the conversation context. This is useful for simple question-answering where tool use isn't needed.
## Limits & Safeguards
| Constraint | Default | Notes |
| --------------------------- | ----------------- | ------------------------------------------------------------------------------------- |
| Max ReAct steps | 25 | Configurable per run. On the final step, tools are withheld to force a text response. |
| Doom loop detection | 3 identical calls | If the last 3 tool calls have the same name and arguments, the run fails. |
| Output truncation recovery | 3 retries | If the LLM output is truncated, the agent retries with a continuation prompt. |
| Context compaction failures | 3 attempts | After 3 failed compaction attempts, the agent stops trying to compact. |
| LLM retries | 3 | Automatic retry on transient LLM errors. |
| Custom/MCP tool timeout | 30 seconds | Per-tool execution timeout. |
| Tool result truncation | 50,000 chars | Results beyond this are truncated. KB search and delegation have no limit. |
| Approval timeout | 300 seconds | Configurable via hook config. Blocks tool execution until decision arrives. |
| Max orchestration depth | 3 | Prevents infinite recursive delegation between agents. |
## Next Steps
Create an agent, assign tools, and start chatting.
Add MCP servers, hooks, and approval flows.
Coordinate multiple agents for complex tasks.
Full endpoint documentation.
# Using the Platform with AI Coding Assistants
Source: https://docs.powabase.ai/concepts/ai-coding-assistants
Powabase's REST API is built to be consumed by AI coding assistants like Claude Code, GitHub Copilot, Cursor, and others. How to integrate effectively, plus a preview of the upcoming skill framework.
## Why AI Coding Assistants?
The platform's REST API is the primary interface for building AI applications. A coding assistant can write integration code faster by reading the API structure, generating correct requests, and debugging responses. Every endpoint follows the same patterns for authentication, error format, and streaming, so an assistant with the right context produces working integration code with minimal guidance.
## Getting Started with Claude Code
Claude Code works well with the Powabase API: it can read this documentation, follow the type system, and generate correct API calls in Python, TypeScript, or cURL. For the best results, give Claude Code your project's base URL and API key, and point it at the specific API section you're working with.
**Share your project context**
Grab your Project URL and Service Role (Secret) Key from the Studio's Connect modal (click the Connect button in your project header), then tell your coding assistant: "I'm building with the Powabase API. My base URL is \{BASE\_URL} and I'm using the Service Role key for authentication. I need to \[create a knowledge base / build an agent / set up a workflow]." That's enough context for the assistant to generate correct code.
## Common Patterns
The most common tasks AI coding assistants help with when integrating Powabase:
| Task | What to Ask For | Key API Endpoints |
| ------------------- | --------------------------------------------------- | ----------------------------------------------------------------------- |
| RAG pipeline setup | Upload documents, create KB, index, and search | /api/sources/upload, /api/knowledge-bases, /api/knowledge-bases//search |
| Agent creation | Create agent with tools and KB, test with streaming | /api/agents, /api/agents//tools, /api/agents//run/stream |
| SSE stream parsing | Parse Server-Sent Events in your language | /api/agents//run/stream (data: format) |
| Multi-agent setup | Create orchestration with entity agents | /api/orchestrations, /api/orchestrations//entities |
| Workflow automation | Build workflow graph with blocks and edges | /api/workflows, /api/workflows//graph |
| Webhook integration | Deploy workflow and set up external trigger | /api/workflows//deploy, /api/workflows//arm |
## Upcoming: Skill Framework
We are developing a skill.md framework that gives AI coding assistants structured knowledge of the Powabase API. Instead of relying on general documentation, the skill file provides the assistant with decision trees, common patterns, error handling strategies, and code templates so it can build complete integrations with less hand-holding.
The skill framework will include: API reference in a format suited to LLM consumption, decision logic for choosing indexing strategies and retrieval methods, end-to-end integration templates for common use cases (RAG chatbot, document processing pipeline, multi-agent support team), streaming event parsers for all three languages, and error recovery patterns.
**Coming soon**
The skill.md framework is under active development. When released, you'll be able to add it to your project's CLAUDE.md or .cursorrules file to give your AI assistant full platform knowledge.
## Tips for All AI Assistants
These practices improve results with any AI coding assistant:
| Tip | Why |
| ------------------------------------------------ | --------------------------------------------------------------------------------------- |
| Provide your base URL and API key format | The assistant can generate ready-to-run code instead of placeholder-filled templates |
| Reference specific API sections | "Use the Knowledge Bases API to..." is more effective than "set up search" |
| Ask for streaming code in your specific language | SSE parsing differs significantly between Python (requests), TypeScript (fetch), and Go |
| Request error handling | The API returns consistent error objects; ask the assistant to handle them |
| Start with the Quickstart pattern | Upload → Index → Agent → Stream is the canonical flow |
## Next Steps
The canonical RAG agent flow to give your AI assistant as context.
Understand the streaming protocol for code generation.
High-level architecture context for your AI assistant.
# Querying the ai schema via PostgREST
Source: https://docs.powabase.ai/concepts/ai-schema-postgrest
Every AI-surface table (sources, knowledge_bases, agents, runs, sessions, workflows) is queryable via /rest/v1/* under RLS. Use it for dashboards, bulk operations, custom analytics, and anything the typed /api/* endpoints don't cover.
The typed `/api/*` surface gives you opinionated, RPC-style access to the AI features. Underneath, all the state those endpoints manage lives in a single Postgres schema, `ai`, and PostgREST exposes that schema as a standard REST CRUD surface at `/rest/v1/*`.
That means **everything you can do with `/api/agents`, `/api/sessions`, `/api/runs`, `/api/knowledge-bases`, etc., you can also do directly against the underlying tables.** SQL-style filters, joins, JSONB selectors, pagination via `Content-Range`, embeds: all the PostgREST machinery that powers `public.*` access works for `ai.*` too. This is the right tool when:
* You need filters or joins the typed endpoint doesn't expose (e.g., "all runs from the last 7 days where the agent's `system_prompt` contains 'refund'").
* You're building a dashboard and want one query for usage analytics instead of N round-trips.
* You're bulk-tagging or migrating sources and don't want to call `PATCH /api/sources/{id}` in a loop.
* You want Realtime subscriptions on a state change the typed API doesn't broadcast.
It's **not** the right tool for state changes the platform's own services manage (run lifecycle, indexing dispatch, workflow execution). Those endpoints aren't just "a CRUD with extra steps"; they coordinate Celery tasks, RAG pipelines, and external LLM calls. Use the typed `/api/*` for state-changing operations on dynamic state, and PostgREST for reads and for writes on static, user-owned configuration.
## How `ai.*` is exposed
PostgREST is configured to serve four schemas: `public, storage, graphql_public, ai`. The `ai` schema sits alongside your own `public` schema as a fully routed PostgREST surface. Every table follows the standard PostgREST URL pattern:
```
GET /rest/v1/{table} # list
GET /rest/v1/{table}?id=eq.{id} # filter
POST /rest/v1/{table} # insert
PATCH /rest/v1/{table}?id=eq.{id} # update
DELETE /rest/v1/{table}?id=eq.{id} # delete
```
To target the `ai` schema instead of `public`, add the `Accept-Profile` (read) or `Content-Profile` (write) header:
```bash theme={null}
curl '{BASE_URL}/rest/v1/agent_runs?limit=10' \
-H "Accept-Profile: ai" \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
```
Without `Accept-Profile`, PostgREST looks in the default `public` schema and 404s.
## Tables in the ai schema
Every state-bearing entity in the AI surface has a corresponding table. The full list (35 tables):
| Domain | Tables |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Sources** | `sources`, `indexed_sources` |
| **Knowledge bases** | `knowledge_bases`, `chunks`, `embeddings`, `enrichment_configs` |
| **Strategy-specific item tables** | `page_index_toc`, `page_index_nodes`, `full_documents`, `doc2json_documents`, `graph_index_toc`, `graph_index_nodes` |
| **Agents** | `agents`, `agent_sessions`, `agent_runs`, `agent_tools`, `agent_knowledge_bases`, `agent_mcp_servers`, `hooks` |
| **Orchestrations** | `orchestrations`, `orchestration_entities`, `orchestration_sessions`, `orchestration_runs` |
| **Workflows** | `workflows`, `workflow_blocks`, `workflow_edges`, `workflow_executions`, `workflow_block_logs` |
| **Tools** | `tools` |
| **Context** | `context_handlers`, `tool_call_events`, `message_citations` |
| **Copilot** | `copilot_sessions`, `copilot_messages` |
| **Settings** | `project_settings`, `ai_provider_keys` |
Schemas can grow; consult `GET /rest/v1/` with `Accept-Profile: ai` for the live OpenAPI spec PostgREST emits.
## RLS posture
Every `ai.*` table has Row Level Security enabled. The default policies are:
* **`service_role`**: full access on every table (`FOR ALL USING (true) WITH CHECK (true)`). This is what the platform's own backend uses.
* **`authenticated`** (any signed-in GoTrue user): **read access on every table**. Write access on most user-configurable tables (agents, workflows, tools, knowledge\_bases, sources, etc.). Per-user filtering on session-shaped tables (`agent_sessions`, `agent_runs`, `orchestration_sessions`, `orchestration_runs`) via `auth.uid() = user_id`.
* **`anon`**: **no access**. Querying `ai.*` with the Anon (Publishable) Key returns empty result sets.
What this means in practice:
| Caller | Reads | Writes |
| ---------------------------------- | ------------------------- | ---------------------------------------- |
| Service Role (Secret) Key | Everything | Everything |
| Signed-in user JWT (authenticated) | Everything in the project | Most config tables, scoped runs/sessions |
| Anon (Publishable) Key | Nothing | Nothing |
**Multi-user projects: `authenticated` is project-wide, not per-user.**
The default policies grant `authenticated` blanket read access to every record in the project, including other users' agents, workflows, and knowledge bases. Per-user scoping only applies to `agent_sessions`, `agent_runs`, `orchestration_sessions`, and `orchestration_runs` (where the `user_id` column lets `auth.uid()` filter).
If your project has multiple end-users and you don't want them seeing each other's data, you must either (a) tighten the default policies, (b) gate `ai.*` reads behind your own backend with the Service Role key, or (c) only let end users hit `/api/*` (the typed surface enforces ownership where it matters).
See the [RLS Model](/concepts/rls-model) page for how `auth.uid()`, `auth.jwt()`, and the role mapping work, and the [RLS Cookbook](/guides/rls-policies) for concrete tightening patterns.
## When to use PostgREST vs `/api/*`
| You want to… | Use… |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Create an agent / workflow / KB | `POST /api/{resource}` |
| Run an agent | `POST /api/agents/{id}/run` or `/run/stream` |
| List/filter/sort/paginate by SQL-style criteria | `GET /rest/v1/{table}` |
| Bulk-update tags or names | `PATCH /rest/v1/{table}` with a filter |
| Join across resources in one request | `GET /rest/v1/{table}?select=*,relation(*)` |
| Aggregate (count, sum, group by) | RPC or `Range-Unit` headers via PostgREST |
| Subscribe to row changes | Realtime `postgres_changes` on `ai.*` |
| Delete a source from a KB | `DELETE /api/knowledge-bases/{id}/sources/{indexed_source_id}` (NOT direct PostgREST — cascades through 8 tables and revokes Celery tasks) |
| Cancel/cleanup mid-flight indexing | `POST /api/knowledge-bases/{id}/sources/{indexed_source_id}/cancel` (typed endpoint handles Celery revoke) |
Anything where the platform runs side-effects (Celery dispatch, LLM calls, file processing) belongs on the typed surface. Everything read-only or static-config belongs anywhere.
## Next steps
Four worked examples: hybrid search on chunks, usage analytics on runs, bulk source tagging, cross-KB joins.
How roles, JWT claims, and auth.uid() compose to gate ai.\* and public.\* access.
Filter operators, embeds, headers, error responses.
The three ways into your project's Postgres — typed API, PostgREST, direct connection.
# API access (raw HTTP)
Source: https://docs.powabase.ai/concepts/api-access
Powabase ships no typed SDKs today. Raw HTTP against the documented endpoints is the supported access path. What that means for your stack, and what's likely coming.
Powabase doesn't currently ship first-party SDKs in any language. The supported way to access the platform from application code is **raw HTTP** against the documented endpoints: `/api/*` for the agentic surface, `/rest/v1/*` for PostgREST, `/auth/v1/*` for GoTrue, `/storage/v1/*` for Storage, `/realtime/v1/*` for Realtime, and the Database URL for direct Postgres.
This page documents the current posture: what "raw HTTP" means in practice, why no SDK ships today, and what's likely on the horizon. For the conventions every endpoint shares, see [API conventions](/concepts/api-conventions).
## What "raw HTTP" means in practice
Every Powabase endpoint is a standard HTTP request. You can talk to it from:
* **Browser `fetch`**: the BaaS surfaces (PostgREST, Auth, Storage) are designed for browser-direct calls with the Anon Key.
* **Node.js `fetch`**, or any HTTP library (`axios`, `got`, `ky`).
* **Python `requests`** or `httpx`.
* **Go `net/http`**.
* **Rust `reqwest`**.
* **`curl`** for one-off testing.
There's nothing in the API beyond what you see in the reference pages. If you can construct an HTTP request with two headers (`apikey` + `Authorization: Bearer`) and a JSON body, you can call any endpoint. The reference pages show snippets in Python, TypeScript, and `curl` for every endpoint, and those are working examples you can copy.
## Why no SDK
A few honest reasons no SDK ships today:
* **The API surface is still moving.** Shipping a typed SDK locks in shapes; reshaping the SDK on every endpoint change is a maintenance burden the platform team chose not to take on yet.
* **Most users get along fine with `fetch`/`requests`.** The endpoints are RESTful enough that a thin wrapper doesn't add much.
* **Multiple SDKs would multiply support burden.** A first-party Python SDK creates an expectation of a first-party TypeScript SDK, a Go SDK, etc. Better to ship none than to ship one and feel obligated to ship four.
If you want SDK-like ergonomics, the most common pattern is rolling a thin wrapper inside your own codebase. A 200-line file that exposes `client.agents.run({id, message})` over `fetch` covers most use cases without adopting an external dependency.
## What `@supabase/supabase-js` won't do
The Supabase JS client (`@supabase/supabase-js`) handles auth, PostgREST, Storage, and Realtime as a unified SDK. It mostly works against Powabase (same endpoints, same auth model, same Realtime protocol), with a few caveats:
* **The agentic `/api/*` surface isn't in the Supabase client.** Agents, knowledge bases, workflows, etc. are Powabase-specific; the Supabase SDK has no methods for them.
* **`/graphql/v1` doesn't exist on Powabase.** If your code uses `supabase.graphql()` it'll 404. Call `/rest/v1/rpc/graphql` directly.
* **The Connect URL pattern is different.** The Supabase SDK accepts a URL plus Anon Key. Powabase's Anon Key works, but you point the SDK at `https://{ref}.p.powabase.ai`, not `https://{ref}.supabase.co`.
* **Storage paths align cleanly but bucket admin endpoints may differ.** Test bucket creation / policy management before relying on it.
For projects porting from Supabase: use `@supabase/supabase-js` for the BaaS surface (PostgREST, Auth, Storage, Realtime), and raw HTTP for the agentic `/api/*` surface. That keeps most of the SDK ergonomics without migrating working code.
## Header pattern reminder
The standard pattern across every endpoint:
```
apikey:
Authorization: Bearer
Content-Type: application/json (for POST/PUT/PATCH)
```
The Anon Key is the routing credential at Kong; the `Authorization` header is what the downstream service uses for role assignment. They can hold the same value (server-side calls with Service Role) or different values (browser-side calls with Anon Key plus user token). See [Auth model](/concepts/auth-model).
## Patterns worth standardizing in your own wrapper
If you're writing a project-specific wrapper, a few patterns to bake in:
### Retry with backoff for 503 / 429
`503 billing service unreachable` is transient; `429 rate limit exceeded` is transient with a known window. Both should retry with exponential backoff. Don't retry `402`, `4xx`, or `401`.
```typescript theme={null}
async function withRetry(fn: () => Promise): Promise {
const delays = [3000, 6000, 12000, 30000];
for (let i = 0; i <= delays.length; i++) {
const res = await fn();
if (res.ok) return res;
if (res.status === 503 || res.status === 429) {
if (i === delays.length) return res;
await new Promise(r => setTimeout(r, delays[i] * (1 + Math.random() * 0.25)));
continue;
}
return res;
}
throw new Error("unreachable");
}
```
### Refresh access tokens before they expire
User access tokens last 1 hour. Refresh at 50 minutes elapsed rather than waiting for a 401:
```typescript theme={null}
const REFRESH_BEFORE = 50 * 60 * 1000; // 50 min
function shouldRefresh(token: { issuedAt: number }) {
return Date.now() - token.issuedAt > REFRESH_BEFORE;
}
```
See [Signup, signin, magic link](/guides/auth-signup-signin) for the refresh endpoint.
### Normalize error shapes
The platform's services have different error envelopes (see [API conventions](/concepts/api-conventions)). A wrapper that normalizes them is worth the small upfront cost:
```typescript theme={null}
type NormalizedError = { code: string; message: string; details?: unknown };
async function normalizeError(res: Response): Promise {
const body = await res.json().catch(() => ({}));
return {
code: body.code || body.error || body.error_description || String(res.status),
message: body.message || body.error_description || body.error || res.statusText,
details: body,
};
}
```
## What's coming
A few realistic things to plan for:
* **OpenAPI spec.** The most likely next addition. Once the surface stabilizes, the platform team is likely to publish an OpenAPI document so users can codegen typed clients in their preferred language. Not committed, but reasonable to anticipate.
* **First-party SDKs.** Less certain. If they do ship, Python and TypeScript are the most likely. Don't build assuming they'll arrive on any timeline.
* **Stable wire format.** The reference pages are the contract today. Changes that would break clients get called out in release notes, and the platform team treats backwards-compatibility seriously even without a formal versioning policy on `/api/*`.
Building on raw HTTP today doesn't lock you out of SDK adoption later, since SDKs would call the same endpoints. The migration cost from raw HTTP to a future SDK is small.
## Next steps
The shared patterns every endpoint uses — the substrate any SDK would wrap.
The Connect modal — where every header value comes from.
For users with existing Supabase client code who want to keep using it.
The terminology that helps when reading endpoint reference pages.
# API conventions
Source: https://docs.powabase.ai/concepts/api-conventions
The shared patterns across Powabase's HTTP APIs: header conventions, naming, error envelopes, pagination, response shapes. Once you know these, every endpoint is easier to read.
Powabase exposes several HTTP surfaces: the agentic `/api/*`, PostgREST `/rest/v1/*`, GoTrue `/auth/v1/*`, Storage `/storage/v1/*`, and Realtime. They were built independently (most are upstream Supabase services Powabase ships) and have slightly different conventions. This page documents the shared patterns and where they diverge.
For specific endpoints, see the relevant `/api-reference/*` pages.
## Header conventions
Two headers on every authenticated request:
```
apikey:
Authorization: Bearer
```
The `apikey` header is required by Kong's `key-auth` plugin for routing. The `Authorization` header is what the downstream service (PostgREST, GoTrue, etc.) actually verifies for role assignment. They can be different values: `apikey: Anon` + `Authorization: Bearer ` is the standard browser-side pattern after sign-in.
For Realtime WebSocket connections, both pieces come in as query parameters: `?apikey=&vsn=1.0.0`. WebSocket APIs in browsers don't let you set headers on the upgrade request.
## PUT vs PATCH for updates
The agentic `/api/*` surface is inconsistent here:
| Resource | Update method |
| -------------------------- | ------------- |
| `/api/agents/{id}` | PATCH |
| `/api/tools/{id}` | PUT |
| `/api/orchestrations/{id}` | PUT |
| `/api/sources/{id}` | PATCH |
| `/api/workflows/{id}` | PATCH |
The intent was that PATCH does partial updates (body keys not present aren't changed) and PUT does full replacement, but in practice the platform's PUT endpoints also work as partial updates. The choice is historical, not principled.
PostgREST uses PATCH for partial updates and supports PUT only when you specify `Prefer: resolution=merge-duplicates` (which is really an upsert).
GoTrue uses PUT on `/user` and `/admin/users/{id}` — both partial.
Storage uses PUT to overwrite an object at the same path.
If you're writing client code, match what the docs say for each endpoint. Don't try to derive PUT-vs-PATCH from first principles.
## Path versioning
The BaaS services have versioned paths: `/auth/v1/`, `/rest/v1/`, `/storage/v1/`, `/realtime/v1/`. The agentic `/api/*` surface is **not** versioned in the path.
This isn't a hard product commitment, just where things stand today. If the agentic surface needs versioning later, the most likely move is to introduce `/api/v2/*` alongside the existing `/api/*`. To future-proof, build a tiny indirection layer in your client code that constructs the URL from a constant.
## Resource naming
Most resources use plural-noun URLs:
* `/api/agents/{id}` (not `/api/agent/{id}`)
* `/api/knowledge-bases/{id}` (with a hyphen)
* `/api/ai-provider-keys` (also hyphenated)
A handful of endpoints use snake\_case in the path:
* `/api/ai-provider-keys/platform_supported` (underscore; this is the one most people get wrong)
* `/api/config/kb-defaults` (hyphen)
Match the docs literally. The platform's routing is case- and character-sensitive.
## Error envelopes
The agentic `/api/*` surface uses `{"error": ""}` as the standard error envelope. Some endpoints add `code` or `error_code` for machine-readable identifiers:
```json theme={null}
{ "error": "Webhook not found", "error_code": "WORKFLOW_NOT_FOUND" }
```
```json theme={null}
{ "error": "BYOK key decrypt failed", "code": "provider_key_decrypt_failed", "provider": "openai" }
```
PostgREST uses a different shape, a JSON object with `code`, `message`, `details`, `hint`:
```json theme={null}
{
"code": "23505",
"message": "duplicate key value violates unique constraint",
"details": "Key (email)=(alice@example.com) already exists.",
"hint": null
}
```
The `code` is the Postgres SQLSTATE (5-character class).
GoTrue uses yet another shape: `{"error": "code", "error_description": "..."}` for OAuth-style errors and `{"msg": "..."}` for some validation errors:
```json theme={null}
{ "error": "invalid_grant", "error_description": "Invalid login credentials" }
```
Storage's error shape is `{"statusCode", "error", "message"}`:
```json theme={null}
{ "statusCode": "400", "error": "InvalidRequest", "message": "..." }
```
This diversity is unfortunate. If you're writing a unified error handler in your client, check `response.status` first, then try `.error || .message || .error_description || .msg` in that order.
## Pagination
PostgREST uses HTTP `Range` headers for offset pagination and the `Content-Range` response header for the total count:
```
Range: 0-19
→ Content-Range: 0-19/247
```
The agentic `/api/*` surface uses query string pagination with explicit `limit` and `offset` params:
```bash theme={null}
GET /api/agents?limit=20&offset=0
→ { "agents": [...], "total": 47, "limit": 20, "offset": 0 }
```
Defaults vary per endpoint, usually `limit=50` with a cap of 200.
For listing endpoints that return many items (KB sources, source page texts), prefer the agentic endpoints' explicit pagination. The PostgREST `Range` approach is fine but easier to forget.
## Response shapes for lists
Inconsistency worth knowing about. List endpoints across the agentic surface use a wrapped shape:
```json theme={null}
{ "agents": [...], "total": N, "limit": L, "offset": O }
```
But not always. Some return a bare array. Some return `{ items: [...], total, ... }`. The audit-flagged-and-corrected wrong examples in PR A (#8) were spots where this inconsistency tripped the docs themselves.
When in doubt, log the response and read its shape. The reference pages should show the actual shape; file an issue if you find one that doesn't.
## Idempotency
The agentic `/api/*` surface does not honor an `Idempotency-Key` header (despite the convention being common). Idempotency is computed internally for billing charges (see [Billing model](/concepts/billing-model)) but the API itself doesn't deduplicate user-supplied retry headers.
In practice: retrying a `POST /api/agents/{id}/run/stream` from a timeout creates a second run. Build your retry logic to handle that, usually by waiting longer between retries or by checking session state before retrying.
PostgREST doesn't honor `Idempotency-Key` either. For inserts that need idempotency, use unique constraints + upserts (`Prefer: resolution=merge-duplicates`).
## Common headers worth knowing
A short list of headers that change behavior across multiple endpoints:
| Header | Where | Effect |
| ------------------------------------------- | ---------------- | ------------------------------------------------------------ |
| `Prefer: return=representation` | PostgREST writes | Return inserted/updated rows in response body |
| `Prefer: count=exact` | PostgREST reads | Include total count in `Content-Range` |
| `Prefer: resolution=merge-duplicates` | PostgREST POST | Upsert semantics |
| `Accept-Profile: ` | PostgREST reads | Target a non-public schema |
| `Content-Profile: ` | PostgREST writes | Same, for writes |
| `Accept: application/vnd.pgrst.object+json` | PostgREST reads | Return single object, not array; fail if not exactly one row |
| `x-upsert: true` | Storage upload | Overwrite existing object |
| `Range: 0-19` | PostgREST reads | Offset-based slicing |
## Authentication conventions across services
| Service | Auth shape |
| ---------------------------------- | --------------------------------------------------------------------------- |
| Agentic `/api/*` | `apikey` + `Authorization: Bearer` |
| PostgREST `/rest/v1/*` | `apikey` + `Authorization: Bearer` |
| GoTrue `/auth/v1/*` | `apikey` + `Authorization: Bearer` (Anon Key OR user access token) |
| Storage `/storage/v1/*` | `apikey` + `Authorization: Bearer` |
| Realtime `/realtime/v1/*` (WS) | `?apikey=` query parameter |
| Realtime `/realtime/v1/api` (REST) | `apikey` + `Authorization: Bearer` headers |
| `/api/webhooks/{id}` | `Authorization: Bearer ` or `?token=` — no `apikey` |
The webhook trigger endpoint is the odd one out: no `apikey`, only the webhook secret.
## Next steps
Where the headers all come from and how to assemble them.
The terminology that goes alongside the conventions.
The errors that come from getting the conventions wrong.
The `Prefer` and `Accept` header patterns that change PostgREST behavior.
# Architecture
Source: https://docs.powabase.ai/concepts/architecture
Understand the control plane / data plane split, per-project isolation, authentication model, and database schemas.
New to Powabase? Start with [Platform overview](/concepts/platform-overview) and [Auth & Connection](/guides/auth-connection) for the basics, then return here for the infrastructure details.
## Control Plane vs Data Plane
The platform uses a two-tier architecture. A single shared Control Plane manages organizations, projects, users, and authentication. It provisions an isolated Data Plane for each project, with its own Postgres database, API gateway, auth service, storage, and AI service.
## Per-Project Isolation
Each project gets its own infrastructure stack in its own Kubernetes namespace (`project-{ref}`). One project's data, users, and configuration are fully separate from another's; projects can't see each other's databases, storage buckets, or API keys. Isolation is enforced at three layers:
* **Compute:** each project runs its own Postgres StatefulSet (defaults: 400m CPU, 1Gi memory, 10Gi gp3 storage, overridable per project), its own GoTrue auth pod, its own Storage and Realtime pods, and its own Project Service worker.
* **Network:** a `project-isolation` NetworkPolicy on the namespace allows ingress only from `shared-services` (where Kong and PgBouncer live) and `control-plane` (the management tier). Cross-project pod-to-pod traffic is blocked.
* **Storage:** the Storage API is configured with a per-project S3 prefix; even though buckets share an underlying S3 backend, projects can only access object paths under their own prefix.
## API Authentication
Every API request requires two headers: an apikey header and a Bearer token in the Authorization header, both set to the same key. Powabase ships two keys per project, both surfaced in the Studio's Connect modal. The Service Role (Secret) Key gives full access and bypasses Row Level Security, so use it for server-side calls only. The Anon (Publishable) Key respects RLS policies and is safe to embed in browsers and mobile clients.
**Never expose the Service Role key**
The Service Role (Secret) Key bypasses all Row Level Security policies. Only use it in server-side code. For client-side applications, use the Anon (Publishable) Key with appropriate RLS policies. The Connect modal also exposes JWT Secret and Database URL; both must stay server-side.
Both keys live in the Connect modal in the Studio. Click the Connect button in your project header (or append ?showConnect=true to any project URL) to copy the Project URL, Anon (Publishable) Key, Service Role (Secret) Key, JWT Secret, Database URL, and pre-built Postgres connection strings.
## Request Routing
When you make an API call to your project URL (`[.p.powabase.ai`), the request hits the per-project Kong gateway directly; the control plane is **not** in the data-plane path. Kong matches the project hostname and routes `/api/*` paths to the Project Service (AI features), `/auth/v1/*` to GoTrue (authentication), `/rest/v1/*` to PostgREST (direct database access), `/storage/v1/*` to the Storage API, and `/realtime/v1/*` to the Realtime service.
The wildcard ALB ingress that fronts every project sits in front of Kong with an **idle timeout of 900 seconds**, long enough that agent and workflow SSE streams (which can run for minutes) won't get cut by an intermediary. CORS is permissive by default at the Kong layer (`origins: ["*"]`, `credentials: true`), so browsers can call any project URL directly with the Anon (Publishable) Key without you wiring up an allowlist. If you need a narrower posture for compliance, the gateway config is customizable on self-hosted deployments.
## Database Schemas
Each project database has four user-visible schemas. The `ai` schema is managed by the platform and stores all AI-related data. The `public` schema is yours to use for application data. The `auth` and `storage` schemas are managed by GoTrue and the Storage API respectively.
| Schema | Owner | Purpose | PostgREST exposed? |
| --------- | ----------- | ------------------------------------------------------------------------------- | -------------------------- |
| `public` | You | Your application tables | Yes |
| `ai` | Platform | Sources, knowledge bases, chunks, embeddings, agents, sessions, runs, workflows | Yes (read+write under RLS) |
| `storage` | Storage API | File metadata, bucket configuration | Yes |
| `auth` | GoTrue | User accounts, sessions, tokens | No (use the Auth API) |
PostgREST is configured to expose `public`, `ai`, `storage`, and `graphql_public` schemas. That means you can query, filter, and (with the right RLS posture) write to `ai.*` tables directly via `/rest/v1/*`. This is useful for custom dashboards over agent runs, bulk operations on sources, or any case where the typed `/api/*` endpoints don't cover what you need. Treat `ai.*` writes carefully: the platform's own services assume the schema's invariants, so prefer the typed endpoints for state-changing operations on agent / KB / workflow tables.
## Next Steps
Open the Connect modal, pick the right key, and make your first request.
Learn how document ingestion works.
Query your project database via PostgREST.
# Auth model
Source: https://docs.powabase.ai/concepts/auth-model
How GoTrue issues JWT access and refresh tokens, what's in each one, the role-based session your app sees afterwards, and how the pieces compose with PostgREST RLS.
Powabase's auth layer is [GoTrue](https://github.com/supabase/gotrue) (v2.184.0) running at `/auth/v1/*` on your project URL. When a user signs in, GoTrue mints two JWTs and returns them to your client: a short-lived access token and a longer-lived refresh token. The access token rides along with every subsequent API call as `Authorization: Bearer `. The refresh token is exchanged for a new access token before the old one expires.
This page covers what's in those tokens, how the token lifecycle works (especially refresh-token rotation), and how the resulting database role drives Row Level Security. For the API surface, see [Auth Reference](/api-reference/auth). For doing the signin flow, see [Signup, signin, magic link](/guides/auth-signup-signin).
## The two tokens
GoTrue returns this shape after every successful sign-in / token refresh:
```json theme={null}
{
"access_token": "eyJ...",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 1748563200,
"refresh_token": "v1.MzQ1Njc4OTAyMzQ1Njc4OQ",
"user": { "id": "...", "email": "...", ... }
}
```
**Access token**: a signed JWT. Default lifetime: **1 hour** (`GOTRUE_JWT_EXP=3600`). Send it as the `Authorization` header on every API call, and as the `apikey` header (both must match). When PostgREST gets it, it verifies the signature against the project's JWT Secret, sets the database session role from the `role` claim, and stores the decoded payload as a session-local setting accessible via `auth.uid()`, `auth.jwt()`, `auth.role()`.
**Refresh token**: an opaque, single-use string (not a JWT). Default lifetime: **no expiration** as long as it gets used at least every refresh cycle, but each token can only be exchanged once. You call `POST /auth/v1/token?grant_type=refresh_token` with it before the access token expires. GoTrue returns a fresh access token plus a new refresh token, and invalidates the old one.
Most client SDKs (e.g., `supabase-js`) handle the refresh automatically, so your code just sees a continuously-valid access token. If you're writing your own client, you need to track expiry and refresh in time.
## Inside the access token
The JWT payload that PostgREST and your RLS policies see:
```json theme={null}
{
"iss": "https://{ref}.p.powabase.ai/auth/v1",
"sub": "11111111-1111-1111-1111-111111111111",
"aud": "authenticated",
"role": "authenticated",
"exp": 1748563200,
"iat": 1748559600,
"email": "user@example.com",
"phone": "",
"app_metadata": { "provider": "email", "providers": ["email"] },
"user_metadata": { },
"session_id": "..."
}
```
The claims your policies will reach for:
* **`sub`**: the user's UUID. Returned by `auth.uid()` in SQL.
* **`role`**: `"authenticated"` for signed-in users, `"anon"` for unauthenticated requests using the Anon Key, `"service_role"` for the platform-issued Service Role Key. Drives `SET LOCAL ROLE` in PostgREST.
* **`aud`**: always `"authenticated"` on Powabase (the project provisions GoTrue with `GOTRUE_JWT_AUD=authenticated`).
* **`app_metadata`**: controlled by the platform or your backend (via `PUT /admin/users/{id}` with the Service Role key). Use for roles, feature flags, allowed orgs, anything end users shouldn't be able to change about themselves.
* **`user_metadata`**: controlled by the user. Use for display name, avatar URL, preferences. Don't put anything security-sensitive here; the user can `PUT /auth/v1/user` to change it.
The full payload is reachable from SQL as `auth.jwt()` (returns `jsonb`).
## Refresh token rotation
Powabase has refresh token rotation enabled by default (`GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=true`). Three implications:
1. **Each refresh token is single-use.** Exchange it once; the next attempt with the same token returns `400 invalid_grant`.
2. **The new refresh token must be persisted client-side.** If you lose it (e.g., user closes the tab before localStorage commits), the session is gone and the user has to re-authenticate.
3. **There's a 10-second reuse interval** (`GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=10`). If two refresh requests fire concurrently with the same token (a common race in SPAs), the second one within 10 seconds succeeds rather than 400ing. This forgives the "double-refresh" race.
Rotation is what makes refresh tokens safer than long-lived access tokens. If an attacker steals a refresh token, they can exchange it exactly once before the legitimate client tries and fails, which signals compromise. The 10-second grace window doesn't materially weaken this: the attacker still only gets one fresh access token, and the legitimate client immediately discovers the breach.
## The four roles
Powabase projects come with four pre-defined database roles that PostgREST switches between based on the JWT it receives:
| Role | Granted to | When |
| ---------------- | ----------------------------------------------------------------------------- | ------------------------------ |
| `anon` | Anyone calling with the **Anon (Publishable) Key** as `Authorization: Bearer` | Public/unauthenticated paths |
| `authenticated` | Anyone calling with a **signed-in user's access token** | After successful sign-in |
| `service_role` | Anyone calling with the **Service Role (Secret) Key** | Server-side / trusted backends |
| `supabase_admin` | Direct Postgres connection as the project owner | Migrations, admin scripts |
`anon`, `authenticated`, and `service_role` are GoTrue-issued JWTs with the corresponding `role` claim. `supabase_admin` is a database role you connect as directly, bypassing GoTrue entirely.
Both `anon` and `authenticated` respect Row Level Security. `service_role` and `supabase_admin` bypass it (`BYPASSRLS` is set on the database role definitions).
See [RLS Model](/concepts/rls-model) for the longer treatment of how policies use these roles.
## Email verification and the "autoconfirm" default
Powabase projects ship with **`GOTRUE_MAILER_AUTOCONFIRM=true`** by default, so new signups are confirmed immediately without an email verification step. That's the right setting for prototyping and for apps where you'll verify ownership another way (e.g., paid plans through Stripe). It's the wrong setting if you need to be sure users own their email address.
To turn it on, set `gotrue.autoConfirm: "false"` in your Helm overrides (self-hosted) or in the Studio's auth settings (managed), and configure SMTP credentials. After that, `POST /auth/v1/signup` returns a user record but no session, and the user has to click the verification email link before they can sign in.
SMTP is **not** configured by default. The platform won't try to send emails until you fill in `gotrue.smtpHost` and friends. While SMTP is unset, password recovery, magic links, and email verification silently no-op.
## Multi-factor authentication (TOTP)
TOTP MFA is enabled at the GoTrue level (`GOTRUE_MFA_TOTP_ENROLL_ENABLED=true` and `GOTRUE_MFA_TOTP_VERIFY_ENABLED=true`) but requires your app to drive the enrollment flow. The endpoint surface (`/auth/v1/factors`, `/auth/v1/factors/{id}/challenge`, etc.) is described in the [Auth Reference](/api-reference/auth). Up to 10 factors per user by default.
Phone-based MFA (SMS) is off by default. Turn it on and configure a Twilio account if you want it.
## Rate limits
GoTrue enforces per-IP rate limits at the application layer. The defaults Powabase ships with:
| Endpoint family | Limit | Period |
| ------------------------------------------------- | ----- | ------------- |
| Email-sending (signup, recovery, magic link) | 30 | per hour |
| SMS-sending | 30 | per hour |
| Token refresh (`/token?grant_type=refresh_token`) | 150 | per 5 minutes |
| Verify (signup confirm, recover confirm, etc.) | 30 | per 5 minutes |
| OTP verify | 30 | per 5 minutes |
| Anonymous user creation | 30 | per hour |
Hitting a limit returns `429 over_email_send_rate_limit` (or similar). These are per-project and tunable in Helm overrides. Raise them on self-hosted deployments expecting traffic spikes; for managed-cloud projects, contact support.
## What's *not* in the picture
A few things to be explicit about because they trip people up:
* **No anonymous sign-in by default.** The platform ships with `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false`. If you want anonymous-then-upgrade flows, set it to `true` in Helm overrides. (Most apps don't need it; "Anon Key" is the unauthenticated mode.)
* **No phone-based signup by default.** Same story: `GOTRUE_EXTERNAL_PHONE_ENABLED=false`. Enable it plus Twilio creds if you want phone signup.
* **No CAPTCHA by default.** `GOTRUE_SECURITY_CAPTCHA_ENABLED=false`. The hcaptcha provider is wired up; supply `securityCaptchaSecret` to enable.
* **No password requirements enforced beyond GoTrue defaults.** That's at least 6 characters, with no complexity rule. If you need stronger policies, validate client-side before submitting or via your own backend before calling GoTrue admin endpoints.
## Next steps
End-to-end email/password and magic-link flows in three languages.
Wire up Google, GitHub, and the 20 other providers with PKCE.
Full /auth/v1/\* endpoint catalog.
How the four roles compose with RLS policies on your tables.
# Backups and disaster recovery
Source: https://docs.powabase.ai/concepts/backups-and-dr
What the platform automatically backs up, what's NOT user-callable, and what to do if you need a restore. Honest about the gap between 'we run backups' and 'you can restore yourself.'
Powabase runs automated Postgres backups for every project. They're not user-callable today: there's no `POST /api/backup/restore` and no self-service rollback UI. This page documents what runs, what the retention story looks like, and how to engage with the platform if you need a restore.
For per-resource recovery inside the API (re-running a failed extraction, re-indexing a KB), see the relevant API references. For platform-wide infrastructure decisions, see [Architecture](/concepts/architecture).
## What runs automatically
Each project's database has a scheduled `pg_dump` job that runs daily and streams the output (gzipped) to S3:
* **Schedule:** daily. The exact time depends on the platform's scheduling and isn't user-configurable.
* **Method:** `pg_dump --no-owner --no-privileges` streamed through `gzip` directly into S3. The job doesn't write to local disk; incident 2026-05-03 caught node-eviction problems with the local-temp-file approach on projects with large embeddings tables.
* **Storage:** S3, in a backup bucket separate from your project's Storage bucket. Keyed under `///.sql.gz`.
* **Verification:** the backup job rejects dumps under 100 bytes as suspected silent failures and alerts the platform team.
Backups include the entire database. The `public`, `ai`, `auth`, `storage`, and `extensions` schemas are all dumped together, and a restore would replace everything.
## What's NOT user-callable
To set realistic expectations:
* **No self-service restore.** There's no UI button and no API endpoint that initiates a restore. Restoration requires platform team involvement.
* **No backup list endpoint.** You can't query "what backups do you have for my project?" That's an internal operations question.
* **No restore-to-different-timestamp UI.** The backups are timestamped and S3-versioned, but exposing a "restore to last Tuesday" picker requires UI work that hasn't shipped.
* **No incremental backups.** Each daily backup is a full `pg_dump`. For large databases this is fine; for very-large databases the storage and runtime can add up.
* **No point-in-time recovery.** Postgres WAL streaming isn't configured for off-cluster archiving. The recovery granularity is the daily snapshot.
The honest framing: **the platform backs you up; restoration requires support.** This is fine for "we accidentally deleted production data, please help": file a support ticket and the platform team can restore from the daily snapshot. It is not fine for "we want hourly self-service rollback," which is a feature that doesn't exist.
## Retention
Backup retention is platform-configured, not project-configured. The platform's defaults retain daily snapshots for a window measured in weeks; older snapshots get pruned. The exact window can vary by deployment tier, and enterprise self-hosted deployments may have longer retention.
If you need a specific retention policy (regulatory, compliance) that exceeds the platform default, that's an enterprise-tier conversation with sales.
## What to do if you need a restore
Email support with:
1. Your project ref.
2. The approximate timestamp of the state you want to restore to.
3. What scope you want: full database, or just specific tables (the platform team can extract specific tables from the snapshot if needed).
4. Whether you want a destructive restore (overwrite current state) or a restore-to-new-project (create a fresh project with the restored data, so you can manually merge what you need).
Restoration is usually completed within hours for managed-cloud projects. Plan around that turnaround in your own disaster-recovery runbook.
## What you should do yourself
A reasonable disaster-recovery posture on top of what the platform provides:
### 1. Run your own snapshot via Database URL
You can `pg_dump` your project yourself, on whatever schedule and to whatever destination you control:
```bash theme={null}
pg_dump "postgresql://][:@db.p.powabase.ai:5432/][" \
--no-owner --no-privileges \
| gzip \
> backup-$(date +%Y%m%d).sql.gz
```
This connects through PgBouncer. For a big database this might bump into transaction-mode constraints, since `pg_dump` opens multiple connections and one transaction with everything in it would fail at large enough sizes. For most projects this isn't an issue.
If you do this regularly, your own backup is the fastest path to restore: you control the schedule and the destination.
### 2. Use Storage for user-uploaded files separately
The `pg_dump` only captures the database. Files in Storage (the actual bytes) live in S3, separate from the database. If you delete a file via Storage API, the database row goes (in `storage.objects`) but recovering the file bytes is a separate concern from a database restore.
For files that matter, replicate to your own S3 bucket on upload. Pattern: webhook on `storage.objects` INSERT, copy the file to your bucket. See [DB webhooks](/guides/db-webhooks).
### 3. Decouple write-path side effects
For events you don't want to lose during an outage (payments, signups, and the like), write to your own durable queue *in addition to* the Powabase database. If the Powabase project is unavailable, the queue still has the data; reconciliation happens after recovery.
This is independent of backup/restore. It's just good practice for any system where data loss during a downtime window is unacceptable.
## What's coming
Like observability, a self-service restore API is on the platform's radar but not committed. The plausible near-term addition would be a Studio UI for triggering a restore to a fresh project from a snapshot. That gives users self-service for the "restore but don't overwrite" pattern without exposing the destructive case.
For enterprise customers, longer retention windows and point-in-time recovery options are conversations to have with sales. They're achievable on dedicated infrastructure; they're not standard managed-cloud features.
## Next steps
The other "honest about what's exposed" page.
For organizations that want full control over backup retention and restore procedures.
The schema-change discipline that reduces the likelihood you'll need a restore in the first place.
The infrastructure context: namespace isolation, the per-project StatefulSet.
# Billing model
Source: https://docs.powabase.ai/concepts/billing-model
How Powabase charges in credits, what triggers a charge, the 402 and 503 errors users see when they hit their balance or billing is unreachable, and where BYOK keys fit in.
Powabase bills in **credits**. Every billable operation in the platform debits credits from the project's organization: running an agent, indexing a source, executing a workflow block, searching a knowledge base. Free-tier organizations have a credit allowance that refills monthly; if you run out, the platform returns `402` until the next refill (or until you upgrade or top up).
This page covers the billing model: what costs credits, how the platform decides whether to dispatch a charge or refuse the request, the structure of the `402` and `503` error responses, and where the BYOK (bring-your-own-key) provider keys interact with the credit system.
## What costs credits
A non-exhaustive list of billable operations. Each shows up as a `post_charge` call in the project service:
| Action | When |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agent_run` | An agent run (`POST /api/agents/{id}/run` or `/run/stream`) starts |
| `agent_tool_call` | Each tool call inside an agent run |
| `orchestration_run` | An orchestration run starts |
| `workflow_run` | A workflow execution starts |
| `workflow_block_` | Per workflow block (`agent`, `code`, `general_api`, `platform_api`, etc.) — failed blocks aren't billed |
| `indexing_` | Per 1K tokens indexed (ChunkEmbed, PageIndex, GraphIndex, Doc2JSON, FullDocument) |
| `extraction` | Per page of OCR / text extraction |
| `web_search` / `web_scrape` | Each call to the web tools (standard search / scrape) |
| `web_search_deep` / `web_search_deep_reasoning` | A `web_search` call run with `search_type: "deep"` or `"deep-reasoning"` — Exa's agentic deep search, billed at higher tiers than a standard `web_search` |
| `metadata_enrichment` | Per chunk during enrichment runs |
| `knowledge_search` | Per call to `/api/knowledge-bases/{id}/search` |
The per-action prices and what one credit equates to in dollars are documented separately in the pricing page on powabase.ai. The complete catalog and pricing schedule lives there; this page focuses on the API-side semantics: how charges interact with your requests, and what error responses you'll see if a charge fails.
## When the charge happens
There are two charge timings depending on the operation:
**Pre-dispatch charges:** the platform estimates the cost before doing anything, checks the org's balance, and refuses the request with `402` if the balance is insufficient. This applies to agent runs (the `check_balance_or_503` call before the run starts), knowledge search, and enrichment runs. The estimated cost is the platform's best guess at what the operation will consume; the actual cost is reconciled after.
**Async charges from worker tasks:** long-running operations (source extraction, KB indexing) dispatch the request immediately and let the Celery worker post the actual charge when the work completes. The pre-dispatch path here only validates that the balance is *positive*, not that it covers the entire expected cost. This is "best-effort" billing: a project that runs out of credits mid-extraction completes the in-flight task but blocks new ones until refill.
Workflow executions charge per block as they complete (`charge_workflow_blocks`), so a workflow that runs out of credits mid-execution stops at the first failed block.
## Plan tiers
Powabase has two notional tiers, and only one is live today:
* **`free`:** the only tier in v1. Hard cap: balance must be >= estimated cost or the request returns `402`. Credits refill on the first of each UTC month.
* **`pro`:** wired in the code, not currently in production. When live, it would use a "soft cap" model where balance can briefly go negative up to a configurable grace amount (`BILLING_PAID_TIER_SOFT_CAP_GRACE_CREDITS`) before refusal.
The plan tier is propagated via the `BILLING_PLAN_TIER` env var on the project-service pod and defaults to `"free"`. All API responses you'll see today are free-tier semantics.
## The 402 response
When pre-dispatch detects insufficient balance, the response is:
```json theme={null}
{
"error": "insufficient_credits",
"balance": 1234,
"estimated_cost": 5000,
"renews_at": "2026-06-01T00:00:00+00:00"
}
```
Fields:
* **`balance`:** the org's current credit balance (integer credits)
* **`estimated_cost`:** what the platform estimated the operation would cost
* **`renews_at`:** when the next free-tier refill arrives (first of next UTC month)
The frontend Studio renders this as a "You're out of credits" banner with the renewal date. Your own clients should do the same: when a `402 insufficient_credits` comes back, the right user-facing response is "you're out of credits, refill on X" rather than retrying.
## The 503 response
When the project-service can't reach the billing-service to verify the balance, it returns `503 Service Unavailable` rather than dispatching:
```json theme={null}
{
"error": "billing service unreachable; cannot verify balance"
}
```
This is a **fail-closed** posture: the platform refuses to dispatch a billable request if it can't first check the balance. The alternative (fail-open: dispatch and hope billing comes back later) would let free-tier orgs keep spending past their cap during outages.
There's a per-process 30-second balance cache in front of this check, so transient billing hiccups don't cause user-facing 503s; the cached balance covers most outages. A 503 means **the cache is stale AND billing is unreachable**.
Clients should treat `503` as a retry-able error with backoff (the cache will refresh on the next successful fetch elsewhere in the cluster). It's not a configuration error on your side.
## BYOK provider keys and AI-on-us
Powabase supports two LLM-billing modes:
**AI-on-us:** the platform pays the upstream LLM provider (OpenAI, Anthropic, etc.) and bills you in credits. To use this, you don't need any provider keys yourself; the platform's pod-level env vars (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.) cover the cost. Which providers are AI-on-us-available depends on what the pod has env vars for; query `GET /api/ai-provider-keys/platform_supported` to find out.
**BYOK (bring-your-own-key):** you upsert your own provider key via `POST /api/ai-provider-keys`, and that key is used for inference instead of the platform's. You pay the provider directly (outside Powabase billing); credit charges from Powabase still apply for the platform's compute, indexing, retrieval, etc., but not for the LLM tokens themselves.
You can mix the two: BYOK for OpenAI while using AI-on-us for Anthropic, for example. The agent run looks at the model's provider, checks for a stored BYOK key first, and falls back to AI-on-us only if both (a) no BYOK key exists and (b) the provider is in `platform_supported`.
If you've stored a BYOK key but it can't be decrypted (encryption-key rotation gone wrong, corrupted DB row), the agent run returns:
```json theme={null}
{
"error": "",
"code": "provider_key_decrypt_failed",
"provider": "openai"
}
```
This is rare. If you see it consistently, re-upsert the affected key with `POST /api/ai-provider-keys`.
## Recoupable vs. platform-paid LLM calls
When you bring your own provider key (BYOK), the platform's billing model needs to distinguish "the user paid the upstream LLM provider directly" from "the platform paid the upstream LLM provider with its own key." The platform doesn't recoup the latter from the user's credit balance for the model token cost itself; those calls come out of the platform's own envelope (the AI-on-us flow).
Internally, this is gated by a per-call "recoupable" flag:
* **User-facing LLM calls** (agent runs, workflow agent blocks, orchestration coordinator / entity runs, copilot chat) are wrapped in `recoupable_llm_call()`. When the project has a BYOK key for the provider, the platform skips the `llm_call` charge (the user already paid upstream). When the project does NOT have a BYOK key, the platform charges normally (the platform paid upstream and recoups via the user's balance).
* **Platform-internal LLM calls** (metadata enrichment, indexing-time model calls, query enrichment, reranker calls) are NOT wrapped. These are always charged against the user's balance because the platform always uses its env key for them, regardless of whether the project also has a BYOK key for the same provider.
The practical implication: configuring a BYOK key for a provider you use heavily in agent runs reduces your credit-balance burn for that provider's token cost. It does not reduce other platform-internal token costs (those still consume credits).
## Idempotency keys
Every `post_charge` carries an idempotency key derived from the operation's identifying fields. Specifically:
* **Workflow blocks:** `sha256(org_id + action + execution_id + block_id)`
* **Agent runs:** `sha256(org_id + action + run_id)`
* **Source operations:** `sha256(org_id + action + source_id + task_id)`
A retried request with the same idempotency key returns the original charge result instead of double-billing. This matters for client retries: if you get a timeout, retrying is safe; the platform won't charge you twice for the same operation as long as the keys match.
## What to do client-side
A rough decision tree for handling billing-related errors:
| Response | Right thing to do |
| --------------------------------- | ---------------------------------------------------------------------------------------------- |
| `402 insufficient_credits` | Surface the renewal date to the user; don't retry. Suggest top-up or upgrade. |
| `503 billing service unreachable` | Retry with exponential backoff (start at 5s, double up to 1 minute). |
| `400 provider_key_decrypt_failed` | Re-upsert the BYOK key for the affected provider. Don't retry the original request until then. |
| `429 rate limit` (workflows only) | Back off — you've exceeded 20 executions per minute. See [Rate limits](/concepts/rate-limits). |
## Next steps
The other quantitative limit on the API: workflow executions at 20/min returning 429.
The BYOK API: storing keys, the platform\_supported endpoint, validation.
Patterns that pair BaaS primitives with the AI surface, relevant for cost-aware app design.
Where most credit consumption originates.
# Common pitfalls
Source: https://docs.powabase.ai/concepts/common-pitfalls
The footguns Powabase users hit most: silent failures, wrong field placements, default-permissive RLS on ai.*, the differences between agentic and database webhooks. With concrete fixes.
This page is a list of things people get wrong, with the fix for each. Most are documented elsewhere too; this page exists so you can grep for the specific error message or symptom and jump to the right fix without reading three concept pages.
If you're hitting something here, read the linked source-of-truth page after applying the fix. The pitfalls list is a shortcut; the concept pages are the explanations.
## Auth and tokens
### "You only need the Anon Key client-side; the Service Role Key is server-side only"
The Service Role Key bypasses RLS. Don't embed it in browsers, mobile apps, or anything else that ships to user devices. If you've accidentally shipped it, rotate it from the Studio and treat any data accessed during the leak window as compromised. See [Auth model](/concepts/auth-model).
### `Authorization` header with `Bearer ` (trailing space) returns 401 silently on webhook routes
The **webhook trigger route** (`POST /api/webhooks/{id}`) checks `auth_header.lower().startswith("bearer ")` (with a trailing space) and pulls the token by slicing from index 7. If your client constructs `Authorization: Bearer ${token ?? ""}` and `token` is undefined, you send literally `"Bearer "` and get an empty string as the token. Returns 401 with no useful error. **Fix:** guard the construction so you don't send a Bearer header when the token is missing, or pass the secret via the `?token=...` query parameter instead.
The general `/api/*` auth path uses `auth_header.split()` and is whitespace-tolerant. A `Bearer ` there returns 401 "Authorization header required" rather than a confusing empty-token failure.
### "I'm sending the access token but getting `unauthorized`"
The access token expires after 1 hour. Most client SDKs refresh automatically; if you're rolling your own, you need to call `POST /auth/v1/token?grant_type=refresh_token` before the 1-hour mark. See [Signup, signin, magic link](/guides/auth-signup-signin).
### "The user is signed in but my queries return zero rows"
Probably an RLS policy issue. The signed-in user has the `authenticated` role; check that your tables have policies granting SELECT to `authenticated` for the rows the user should see. See [RLS Cookbook](/guides/rls-policies). If you're hitting `/rest/v1/*` directly, confirm you're sending the user's access token in `Authorization`, not the Anon Key.
## RLS and the ai schema
### "Anyone signed in can read every other user's agents"
This is the default `ai.*` RLS posture. The `authenticated` role has blanket SELECT on most `ai.*` tables; only session-shaped tables (`agent_sessions`, `agent_runs`, `orchestration_sessions`, `orchestration_runs`) filter by `auth.uid()`. For multi-user projects, either tighten the policies or only let end users hit the typed `/api/*`, which the platform's backend enforces ownership against. See [Querying the ai schema](/concepts/ai-schema-postgrest).
### `query.from(...).select()` is empty even though rows exist
If you're querying `ai.*` from PostgREST, you need `Accept-Profile: ai`. Without it, PostgREST looks in `public` and returns nothing (or 404).
### "I added an RLS policy but my old policy still applies"
RLS policies are additive (OR-combined) by default. Adding a permissive policy doesn't replace existing permissive policies; they all apply. If you intended to replace, `DROP POLICY` first.
## Agent runs
### Agent runs with end-user JWTs leak data
The platform does NOT forward end-user JWTs to agent tools. `database_query` and `database_write` builtins run as superuser regardless of who invoked the run. If you expose `/api/agents/{id}/run` to clients with their own access tokens, the agent has full project-wide DB access, not the caller's RLS-filtered view. See [BaaS+AI cookbook Recipe 2](/guides/baas-ai-cookbook). Always run agents from a trusted backend.
### `temperature` at the top level of agent body silently dropped
Agent create/update bodies accept `name`, `model`, `system_prompt`, and `settings`. Top-level `temperature` is silently dropped. Nest it inside `settings`:
```json theme={null}
{"name": "...", "model": "gpt-4o", "settings": {"temperature": 0.7}}
```
### MCP `transport: "sse"` works but `"http"` is the default
The platform's MCP integration accepts both `sse` and `http`, but the default at the database level is `http`. Newer MCP servers use streamable HTTP; SSE is the older variant. Use `http` unless you know your server only supports SSE.
### Agent runs return `provider_key_decrypt_failed`
Your BYOK provider key can't be decrypted. Re-upsert it via `POST /api/ai-provider-keys`. See [Billing model](/concepts/billing-model).
## Workflows
### `{"type": "input"}` block type returns `400 Unknown block type`
The block registry has 10 canonical types: `starter`, `agent`, `code`, `condition`, `general_api`, `platform_api`, `response`, `split`, `webhook`, `orchestration`. `input`, `output`, and `llm` are not real. See [Workflows concept](/concepts/workflows-concept).
### `/execute` body uses `input` but the new field is `variables`
`POST /api/workflows/{id}/execute` accepts both `variables` (canonical) and `input` (legacy alias); the platform reads `data.get("variables", data.get("input", {}))`. Prefer `variables` in new code.
### `/arm` returns `{"ok": true, "armed_until": ...}` not `{"webhook_id", "secret"}`
The arm endpoint doesn't return webhook credentials. The `webhook_id` and `webhook_secret` live in the webhook block's `config`; fetch the workflow and read them from there. See [Workflows reference](/api-reference/workflows).
### Workflow `/execute` returns `429`
The endpoint is rate-limited at 20 requests per minute per user. Back off with jitter and re-try. See [Rate limits](/concepts/rate-limits).
## Storage
### Public-bucket URLs ignore RLS
`GET /storage/v1/object/public/{bucket}/{path}` returns files without auth from any bucket where `public: true`. No RLS check happens on the public URL. If you don't want files publicly fetchable, don't put them in a public bucket; use a private bucket with signed URLs instead. See [Storage policies](/guides/storage-policies).
### File over 50MB returns `413`
The per-request file size limit is 50MB. For larger files, use TUS resumable uploads at `/storage/v1/upload/resumable`. See [Storage uploads](/guides/storage-uploads).
### Storage MIME allowlist isn't real security
The MIME check looks at the `Content-Type` header your client sends, not the actual file contents. A malicious client can upload an executable with `Content-Type: image/png`. Treat the allowlist as UX; validate file contents server-side for security.
## Realtime
### postgres\_changes subscription joined but no events arrive
The `supabase_realtime` publication isn't set up by default on Powabase projects. You have to `CREATE PUBLICATION supabase_realtime FOR TABLE public.your_table` (or `FOR ALL TABLES IN SCHEMA public`). See [Realtime model](/concepts/realtime).
### Realtime returns `403 TenantNotFound`
Only happens on self-hosted deployments where Kong isn't preserving the Host header. Powabase managed cloud handles this; if you see it from managed cloud, file a support ticket.
### WebSocket closes after 60 seconds
You're not sending heartbeats. Send a `{ topic: "phoenix", event: "heartbeat", payload: {}, ref: "..." }` frame every 30 seconds. See [Realtime subscriptions](/guides/realtime-subscriptions).
## Postgres / pooler
### `prepared statement "..." does not exist`
PgBouncer transaction mode breaks prepared statements. Disable them in your driver's config; see [Connection pooling](/guides/connection-pooling) for the per-driver table.
### LISTEN/NOTIFY through the pooler doesn't work
PgBouncer doesn't maintain session-level state across statements. The LISTEN registers on one server connection; the next statement lands on a different one. Use Realtime instead.
### `SET statement_timeout = '5s'` outside a transaction doesn't apply
Same reason as LISTEN/NOTIFY: the `SET` sticks to one server connection. Use `SET LOCAL` inside a transaction, or pass settings via the connection string.
### Username is `][` not `postgres`
Powabase's PgBouncer routes by database name, where the database is `][` and the user is also `][`. Coming from Supabase or a standalone Postgres, the muscle memory of `postgres:postgres@host/postgres` is wrong. Copy the URL from the Connect modal verbatim.
## Webhooks (agentic, inbound)
### Webhook returns 401 even with the right secret
Common cause: extra whitespace in the secret. The platform uses `hmac.compare_digest` which is byte-exact. Also check that you're not sending `Bearer ` with no token (see auth section).
### Webhook returns 403 after firing once
Armed (single-use) webhooks fire exactly once per arm. Re-arm with `POST /api/workflows/{id}/arm`. For unlimited fires, deploy the workflow instead. See [Workflows reference](/api-reference/workflows).
### Webhook secret rotates on arm/deploy?
No. The webhook secret is fixed in the webhook block's config when the block was created. Arming and deploying don't rotate it. To rotate, update the block config via `PUT /api/workflows/{id}/graph`.
## Billing
### Agent run returns `402 insufficient_credits`
Free-tier hard cap. The response includes `balance`, `estimated_cost`, and `renews_at`. Surface the renewal date to the user; don't retry. See [Billing model](/concepts/billing-model).
### Agent run returns `503 billing service unreachable`
Transient. Back off and retry. Don't treat as a permanent error.
## Confusingly named
### "Webhook": agentic vs database
* Agentic webhooks: external systems triggering workflows. `POST /api/webhooks/{id}`.
* Database webhooks: Postgres rows changing and Postgres calling out via pg\_net.
See [Glossary](/concepts/glossary).
### "Session": agent vs auth
* Agent session: a multi-turn conversation. In `ai.agent_sessions`.
* Auth session: a signed-in user's authenticated state. GoTrue-internal.
### "Hook": agent vs database trigger vs GoTrue
* Agent hook: lifecycle callback (PreToolUse, approval, etc.).
* Database trigger: Postgres function fired on row changes.
* GoTrue hook: auth-layer extension point (not exposed on Powabase per-project today).
## Debugging a failed run
When an agent run, workflow execution, or orchestration finishes with `status: failed`, the pieces you need are spread across several endpoints. Check them in this order.
### Step 1: get the run record
`GET /api/agents/runs/{run_id}` returns the full run: `status`, `error`, `usage`, `events` (every SSE event that was persisted), `tool_calls`, `reasoning_steps`, `retrieved_context`, `input_messages`, `output_messages`. The `error` field is the highest-signal place to start.
### Step 2: scan the events array for the first non-success event
The `events` array preserves the order of execution. The first event with a failure shape (`type: "error"`, `type: "tool_error"`, etc.) is usually the actual cause; everything after it is symptom.
Common shapes:
| Symptom in `events` / `error` | Probable cause |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"insufficient_credits"` with `balance: 0` | Free-tier cap hit. The 402 from `check_balance_or_503`. Top up or grant the project credits. |
| `"billing service unreachable"` | 503 from `check_balance_or_503` (fail-closed). The billing service is down — retry. |
| `"Missing API Key"` or `"provider_key_decrypt_failed"` | The agent's model has neither a BYOK key nor a platform env key. Set a key in Settings → LLM Provider Keys. |
| `"Exa API key not configured"` | `web_search` tool called without `EXA_API_KEY` setting. Set in Studio → Settings → Tools. |
| `"Sandbox is not configured"` | `code_execute` called but `CODE_SANDBOX_URL` env not set on the platform. Operator issue. |
| `"Doom loop detected"` | The agent called the same tool with the same args 3 times in a row. Usually means the LLM is misreading the tool's response. Inspect the tool's `result` in `tool_calls`. |
| `"Output truncated"` after 3 retries | LLM hit max\_tokens repeatedly and continuation prompts didn't help. Increase max\_tokens or shorten the system prompt. |
| Empty `output_messages` but `status: completed` | Often means a multimodal context was sent to a non-multimodal model. Check the model's capabilities. |
### Step 3: for retrieval issues, get the retrieved context
`GET /api/sessions/{session_id}/runs/{run_id}/retrieved-context` returns what context was injected into the LLM call. If the agent's answer is "I don't know," compare this to what you expected. Empty or wrong-source context here is the root cause.
### Step 4: for workflow runs, get the per-block logs
`GET /api/workflows/{id}/executions/{execution_id}/logs` returns the per-block logs for a workflow execution. A failed `general_api` block's response body lives here, as do code-block stderr lines.
### Step 5: rate limit?
If the failure is on the 22nd+ workflow execution within a minute, you've hit the in-memory rate limit (20/min per user). Wait 60 seconds or pace your calls. The error is `"Rate limit exceeded"`.
### Step 6: still stuck?
The Studio's run-detail view renders all the above on one page and is usually faster than stitching the API calls yourself. For platform issues (suspected bug, weird state), file a support ticket with the run\_id; the platform team can read the same data plus the internal logs.
## Next steps
The full terminology disambiguation list.
The shared patterns across all `/api/*` endpoints. Once you know them, fewer pitfalls.
A specific subset of pitfalls for users coming from Supabase.
The recipes designed to keep you out of the worst of these.
# Database Access
Source: https://docs.powabase.ai/concepts/database-access
Every project has a full Postgres database accessible three ways: the Project Service API for AI-managed data, PostgREST for application tables, and a direct Postgres connection for migrations and admin tooling. All three credentials come from the Studio's Connect modal.
## Three Ways In
The Project Service API (/api/*) manages the ai schema: sources, knowledge bases, agents, and all other platform-managed data. You interact with this data through structured endpoints. PostgREST (/rest/v1/*) gives you direct RESTful access to the public schema where your application tables live. The Database URL is a standard Postgres connection string. Drop it into psql, pg, psycopg2, JDBC, SQLAlchemy, or any tool that speaks the Postgres wire protocol for migrations, dashboards, or one-off queries.
All three need credentials, and all three credentials live in the same place. Open the Connect modal in the Studio by clicking the Connect button in the project header, or append ?showConnect=true to any project URL. The "API Keys" tab gives you Project URL, Anon (Publishable) Key, Service Role (Secret) Key, JWT Secret, and Database URL. The "Connection Strings" tab ships ready-to-paste Postgres snippets in nine formats (URI, PSQL, Node.js, Python, Golang, JDBC, .NET, PHP, SQLAlchemy).
## AI Schema (Managed)
The ai schema is fully managed by the platform. It contains tables for sources, page\_texts, knowledge\_bases, chunks (with pgvector embeddings), agents, tool\_assignments, sessions, messages, runs, orchestrations, workflows, and more. Never modify these tables directly; use the Project Service API instead.
**Do not modify the ai schema directly**
The ai schema is managed by the platform and may change between versions. Direct modifications can break platform functionality. Always use the Project Service API endpoints.
## Public Schema (Your Tables)
The public schema is yours. Create tables, define relationships, and add indexes as needed. PostgREST automatically exposes all public schema tables as REST endpoints. Read, insert, update, and delete rows using standard HTTP methods, with a full set of filtering operators.
## Row Level Security
PostgREST respects Row Level Security (RLS) policies on your tables. The Service Role (Secret) Key bypasses RLS entirely, so use it only server-side. The Anon (Publishable) Key respects RLS policies, so users only see rows they're authorized to access. This is the recommended pattern for client-side applications.
## Direct Postgres Connection
When you need raw SQL (running migrations, hooking up a BI tool, importing a CSV, wiring an ORM), connect directly to Postgres using the Database URL from the Connect modal. The URL embeds host, port, database, user, and password; treat it like a credential and keep it server-side. The "Connection Strings" tab generates the equivalent snippet for psql, Node.js (pg), Python (psycopg2), Go (database/sql), JDBC, .NET, PHP, and SQLAlchemy so you don't have to hand-assemble DSNs.
**Database URL is a secret**
The Database URL contains the database password in cleartext. Never commit it to source control or ship it to a client. Use it only from trusted backends, migration tools, or local dev, and rotate the database password (Studio -> Settings -> Database) if it leaks.
| Use case | Best surface | Key from Connect modal |
| ---------------------------------------- | ----------------------------- | ------------------------- |
| AI workflows (sources, KBs, agents, ...) | /api/\* (Project Service API) | Service Role (Secret) Key |
| Server-side CRUD on your tables | /rest/v1/\* (PostgREST) | Service Role (Secret) Key |
| Client-side CRUD under RLS | /rest/v1/\* (PostgREST) | Anon (Publishable) Key |
| Migrations, BI tools, ORMs, psql | Postgres wire protocol | Database URL |
| Verifying user-signed JWTs server-side | Your own service | JWT Secret |
## PostgREST Operators
| Operator | Example | Description |
| -------- | -------------------- | -------------------------------- |
| eq | ?status=eq.active | Equal to |
| neq | ?status=neq.deleted | Not equal to |
| gt | ?age=gt.18 | Greater than |
| gte | ?age=gte.18 | Greater than or equal |
| lt | ?price=lt.100 | Less than |
| lte | ?price=lte.100 | Less than or equal |
| like | ?name=like.*Smith* | Pattern match (case-sensitive) |
| ilike | ?name=ilike.*smith* | Pattern match (case-insensitive) |
| is | ?deleted\_at=is.null | IS check (null, true, false) |
| in | ?id=in.(1,2,3) | In a list of values |
## Next Steps
Auto-generated CRUD docs for your public tables.
Manage users and files via GoTrue and Storage APIs.
Understand the database schema layout.
# Glossary
Source: https://docs.powabase.ai/concepts/glossary
Disambiguating the terms Powabase uses that overlap with each other or with terms from other platforms: agent sessions vs auth sessions, agent hooks vs DB webhooks, etc.
A few terms in the Powabase docs do double duty depending on context. This page disambiguates the ones that come up most. If you've spent any time reading the rest of the docs and wondered "wait, which 'session' are we talking about," this is the page for that.
## Sessions
**Agent session** (in `ai.agent_sessions`). A multi-turn conversation between a user and a Powabase agent. Holds the message history, context, and per-run state. Created automatically on the first agent run with a session id, persists across runs until explicitly deleted. Owned by `user_id` (the GoTrue JWT's `sub` at creation time) and has its own RLS policies.
**Auth session** (in GoTrue's internal state). A signed-in user's authenticated state, represented client-side as an access token + refresh token. Created by `POST /auth/v1/token?grant_type=password` etc., expires when the refresh token is invalidated. Owned by the user themselves; no direct table you query.
The two are independent. You can have an active auth session and zero agent sessions, or many agent sessions and no current auth session (if you're calling agent runs from a backend with the Service Role key).
## Runs vs executions
**Agent run** (in `ai.agent_runs`). One invocation of a single agent. Has a `run_id`, belongs to a session, includes the LLM steps, tool calls, retrieved context, and final response. Streamed via the SSE protocol at `/api/agents/{id}/run/stream`.
**Workflow execution** (in `ai.workflow_executions`). One execution of a workflow's block graph. Has an `execution_id`, includes per-block logs (`ai.workflow_block_logs`). May contain zero or many agent runs as part of its block sequence.
**Orchestration run** (in `ai.orchestration_runs`). One coordination cycle of a multi-agent orchestration. The supervisor agent runs, decides which entities to delegate to, each entity's invocation may produce its own agent\_runs.
All three are "things that happened that you can query for status and logs."
## Webhooks
**Agentic webhook** (the inbound side, `POST /api/webhooks/{webhook_id}`). External systems triggering deployed or armed workflows. Bare-bearer-token auth, no body HMAC. See [Webhooks reference](/api-reference/webhooks).
**Database webhook** (the outbound side, `supabase_functions.http_request()` trigger function). Postgres rows changing and Postgres calling out to some HTTP endpoint via pg\_net. See [DB webhooks](/guides/db-webhooks).
They're named identically and easily confused. The agentic kind is "things calling Powabase"; the database kind is "Powabase calling things."
## Hooks
**Agent hook** (in `ai.hooks`). Lifecycle callback on an agent at one of six events (`OnRunStart`, `PreToolUse`, `OnDelegation`, `PostToolUse`, `PreResponse`, `OnRunComplete`), in one of three types (`http`, `rule`, `approval`, the last being the human-approval flow). Added to an agent via the hooks API, fires during agent runs. See [Hooks & Middleware](/concepts/agents-tools#hooks--middleware).
**Database trigger function** ("trigger hook" in some Postgres docs). A function that runs on table changes, via `CREATE TRIGGER`. Sometimes loosely called a "hook." Different mechanism, different surface.
**GoTrue hook** (e.g., `before_user_created`). Auth-layer extension points. Not currently exposed on Powabase's per-project GoTrue (only the control-plane GoTrue has the `block_disposable_email_signups` hook).
## Tools
**Builtin tool** (in code, registered in `tools/builtin.py`). One of the eight tools every agent can be granted: `database_query`, `database_write`, `http_request`, `code_execute`, `storage_read`, `storage_write`, `web_search`, `web_scrape`.
**Custom tool** (in `ai.tools`). A user-defined tool record. Has a `type` field stored as a free-form string (not used by dispatch) and a `config` blob describing how to call it.
**Tool assignment** (in `ai.agent_tools`). The link between an agent and a tool: "this agent can use that tool." Dispatch reads from `agent_tools.tool_type` (which is `'builtin'` or `'custom'`), not from the Tool's own `type` field.
**MCP tool** (discovered at runtime via `tools/list` JSON-RPC). Provided by an MCP server attached to an agent. Namespaced as `mcp____` when the agent calls them.
A "tool" in conversation could mean any of these depending on context. When precise: builtin / custom / MCP.
## API key types
**Anon (Publishable) Key**. Long-lived JWT with `role: "anon"`. Safe to embed in browsers. PostgREST treats it as the `anon` Postgres role; RLS policies decide what it can see.
**Service Role (Secret) Key**. Long-lived JWT with `role: "service_role"`. Bypasses RLS. Server-side only.
**User access token**. Short-lived JWT (1 hour) with `role: "authenticated"`. Returned by GoTrue after sign-in. PostgREST treats it as the `authenticated` Postgres role; RLS gates what specifically.
**Refresh token**. Opaque string (not a JWT). Single-use; exchanged at `POST /auth/v1/token?grant_type=refresh_token` for a new access+refresh pair.
**Database URL**. Postgres connection string for direct SQL access via PgBouncer. Authenticates as `supabase_admin`. Full schema ownership.
The Connect modal in the Studio surfaces the **Project URL**, **Anon Key**, **Service Role Key**, **JWT Secret**, and **Database URL**, plus nine driver-specific connection-string snippets (psql, URI, Node.js pg, Python psycopg2, Go database/sql, JDBC, .NET, PHP, SQLAlchemy). The **user access token** and **refresh token** are runtime outputs from GoTrue after sign-in; they don't appear in the modal because they don't exist until a user signs in.
## Schemas (the Postgres kind)
**`public`** schema. Where your application tables live. You own it.
**`ai`** schema. Where the AI surface's state lives. Platform-owned but queryable via PostgREST under RLS. See [Querying the ai schema](/concepts/ai-schema-postgrest).
**`auth`** schema. Where GoTrue's state lives. Platform-owned, not exposed via PostgREST.
**`storage`** schema. Where Storage API metadata lives. Platform-owned, exposed via PostgREST so you can apply RLS to objects.
**`extensions`** schema. Where Postgres extensions install their objects (pg\_net, citext, etc.). You own it.
See [Schemas](/concepts/schemas) for the longer treatment.
## Things named "config"
**`indexing_config`** / **`retrieval_config`** (on knowledge bases). Per-KB tuning for the chosen indexing strategy and retrieval method. Strategy-specific shape.
**`config`** (on tools). The dispatch config for a custom tool: endpoint, method, headers, timeout for HTTP tools.
**`config`** (on workflow blocks). The block's parameters, which depend on block type (agent\_id for agent blocks, code for code blocks, etc.).
**Block config vs block output**. Block config is the static template; block output is the per-execution result that downstream blocks reference.
## Strategies
**Indexing strategy** (`ai.knowledge_bases.indexing_config.strategy`). One of `chunk_embed`, `full_document`, `page_index`, `graph_index`, `doc2json`. Decides how documents become searchable items.
**Retrieval method** (`ai.knowledge_bases.retrieval_config.method`). One of `vector_search`, `full_text`, `hybrid`, `tree_search`. Decides how queries become results. Independent of strategy though some pairings are nonsensical.
**Orchestration strategy** (`ai.orchestrations.strategy`). One of `supervisor`, `sequential`, `parallel`. Decides how the coordinator routes between entity agents.
The word "strategy" is overloaded. Always qualify which.
## Realtime channel types
**Broadcast**. Ephemeral message routing across subscribers. No persistence; clients not connected when a message fires miss it.
**Presence**. Tracked-state sync: each client publishes its own "I'm here" state, everyone sees the aggregate.
**Postgres Changes**. Row-level event stream from the database's logical replication slot. Filtered by schema/table/event/column.
A single channel can use one, two, or all three at once.
## When in doubt
If you see a term in the docs you can't disambiguate from context, the right approach is grep the rest of the docs for it and look at which page it appears on:
* Mentioned in `/concepts/auth-model` → likely auth-side
* Mentioned in `/concepts/agents-tools` → likely agent-side
* Mentioned in `/concepts/workflows-concept` → workflow-side
* Mentioned in `/api-reference/` → the surface that page documents
## Next steps
The footguns these terminology disambiguations come up around.
The Postgres-side disambiguation in more depth.
The HTTP-layer naming patterns the API uses.
For users coming from Supabase: what's the same and what's different.
# Knowledge Bases & Indexing
Source: https://docs.powabase.ai/concepts/knowledge-bases-indexing
The platform's Context Engineering suite turns raw documents into searchable knowledge. Choose from five indexing strategies and four retrieval algorithms to build the right pipeline for your use case, whether that's simple vector search or LLM-driven document reasoning.
## What is a Knowledge Base?
A Knowledge Base (KB) is a container that holds one or more sources and makes their content searchable. When you add a source to a KB, the platform processes the extracted text using the configured indexing strategy and stores the results for retrieval. The indexing strategy determines how content is structured: simple chunking, hierarchical document trees, or structured JSON extraction. The retrieval strategy determines how queries find relevant content, anywhere from fast vector similarity to LLM-driven reasoning over document structure.
## Indexing Pipeline
The indexing pipeline runs automatically when you add a source to a knowledge base. What happens during indexing depends on the strategy: ChunkEmbed splits text and generates embeddings, Full Document generates one summary per document and returns the entire text on match, PageIndex builds a hierarchical tree with LLM-generated summaries, GraphIndex extends PageIndex with cross-reference detection and node embeddings, and Doc2JSON extracts structured fields. You can reindex at any time to reprocess with different settings.
]
## Indexing Strategies
The indexing strategy controls how source content is processed and stored. Each strategy produces different data structures, supports different retrieval methods, and has different cost profiles. You set the strategy when creating or updating a knowledge base via the indexing\_config field.
| Strategy | Best For | Cost | Indexing Speed | Retrieval Speed | Compatible Retrieval |
| ------------- | -------------------------------------------------- | ----------------------------------------- | ----------------------------------------- | ---------------------------- | --------------------------------------------- |
| ChunkEmbed | General RAG, most documents | Low (embedding only) | Fast | Fast | Vector, Hybrid, Full-text |
| Full Document | Whole-document retrieval, small/medium collections | Low (one summary LLM call per document) | Fast | Fast | Vector, Hybrid, Full-text (search on summary) |
| PageIndex | Long structured PDFs, complex docs | Medium–High (many LLM calls) | Slow (many LLM calls) | Slow (LLM at query time) | Tree Search only |
| GraphIndex | Cross-referenced documents | High (PageIndex + enrichment + embedding) | Slow (PageIndex + enrichment + embedding) | Fast (vector/hybrid, no LLM) | Vector, Hybrid, Full-text + graph expansion |
| Doc2JSON | Structured field extraction | Medium (LLM per window) | Medium (LLM per window) | Fast | Vector on summary |
### ChunkEmbed (Default)
The standard RAG approach. Text is split into overlapping chunks using a configurable chunking strategy, each chunk is embedded into a vector, and the vectors are stored in pgvector for similarity search. A BM25 sparse index is also built over the chunk text, enabling keyword-based full-text search alongside vector search. This is the fastest and cheapest indexing strategy: no LLM calls are needed, only an embedding API call.
ChunkEmbed pairs with vector search, hybrid search, or full-text search for retrieval. Hybrid search (which runs both vector and BM25 in parallel and fuses results) is the recommended default for production RAG applications.
### Chunking Strategies
ChunkEmbed uses the **markdown\_header** chunker: it splits at Markdown headers (h1–h6), then subdivides each section by length using recursive chunking, and prepends the section header as context to each chunk. This works well across both Markdown-structured documents and unstructured prose (where it falls through to the recursive subdivision step).
Chunk size and overlap control the tradeoff between precision and context. Smaller chunks (500–1000 tokens) give more precise retrieval but may lose surrounding context. Larger chunks (2000–4000 tokens) preserve context but can dilute relevance. The defaults (2000 tokens, 50 token overlap) work well for most use cases. Tune them via `indexing_config.chunk_size` and `indexing_config.chunk_overlap`.
```python Python theme={null}
# Create a KB with ChunkEmbed (the default strategy)
response = requests.post(
f"{BASE_URL}/api/knowledge-bases",
headers=headers,
json={
"name": "Product Docs",
"indexing_config": {
"strategy": "chunk_embed",
"chunk_size": 1500,
"overlap": 100,
"embedding_model": "text-embedding-3-small",
},
},
)
kb = response.json()
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Product Docs",
indexing_config: {
strategy: "chunk_embed",
chunk_size: 1500,
overlap: 100,
embedding_model: "text-embedding-3-small",
},
}),
});
const kb = await res.json();
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Product Docs",
"indexing_config": {
"strategy": "chunk_embed",
"chunk_size": 1500,
"overlap": 100,
"embedding_model": "text-embedding-3-small"
}
}'
```
### Full Document
Full Document treats every source as a single retrievable unit. At indexing time, the platform makes one LLM call to generate a comprehensive summary of the document, embeds the summary, and stores the original full text in object storage with its path recorded in the database. A BM25 sparse index is also built over the summaries, so vector, full-text, and hybrid search are all supported. At query time, search runs against the summaries, but each match returns the **entire original document text**, not a chunk or section.
This makes Full Document the right choice when you want the agent to reason over whole documents rather than fragments: short legal cases, individual articles, policy memos, research papers, or any collection where each document is a self-contained unit small enough to fit in the agent's context window. Indexing is cheap (one LLM summary per document, no chunking work) and retrieval is fast (no LLM calls at query time, just standard vector or BM25 lookups).
**Default top\_k is 3**
Because each result is a whole document, typically much larger than a single chunk, the default top\_k is 3 instead of the usual 5. Tune this based on your average document size and the agent's context budget. Returning two or three documents is usually plenty; returning ten can easily exceed available context.
**Summarisation truncates very long inputs**
The summary LLM call sees only the first \~32,000 tokens of the document. For longer documents, content beyond that point is captured in the stored full text (which is still returned on a match) but is not represented in the summary used for retrieval. If your documents routinely exceed 32K tokens of unique content per topic, ChunkEmbed or PageIndex will give better retrieval coverage.
```python Python theme={null}
# Create a KB with Full Document for whole-document retrieval
response = requests.post(
f"{BASE_URL}/api/knowledge-bases",
headers=headers,
json={
"name": "Case Law",
"indexing_config": {
"strategy": "full_document",
"summary_model": "gpt-5-mini",
"embedding_model": "text-embedding-3-small",
},
"retrieval_config": {
"method": "hybrid",
"top_k": 3,
},
},
)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Case Law",
indexing_config: {
strategy: "full_document",
summary_model: "gpt-5-mini",
embedding_model: "text-embedding-3-small",
},
retrieval_config: {
method: "hybrid",
top_k: 3,
},
}),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Case Law",
"indexing_config": {
"strategy": "full_document",
"summary_model": "gpt-5-mini",
"embedding_model": "text-embedding-3-small"
},
"retrieval_config": {"method": "hybrid", "top_k": 3}
}'
```
### PageIndex
PageIndex uses LLM analysis to build a hierarchical tree of your document's structure: sections, subsections, and their content. The result is stored as two artifacts: a lightweight ToC (titles and LLM-generated summaries, no full text) and a flat list of section nodes (with the actual text). Retrieval works in two phases. An LLM first reasons over the lightweight ToC to identify relevant sections by structure, then the platform fetches the full text of those sections.
PageIndex has two pipelines that are selected automatically. For Markdown content, it parses headers into a nested tree, splits oversized leaf nodes using LLM calls, and generates per-node summaries. For PDFs (when page\_texts are provided), it scans the first pages for a table of contents, calibrates page-number offsets against actual headings, infers structure via LLM if no ToC is found, then assigns page text to tree nodes and merges small siblings. Both pipelines cap LLM concurrency at 7 calls by default to avoid rate limits.
**PageIndex is expensive to index**
PageIndex makes many LLM calls during indexing: ToC detection, structure inference, oversized node splitting, and summary generation. A 100-page PDF may take several minutes and consume significant LLM tokens. Use this strategy when document structure matters for retrieval quality, such as compliance manuals, legal contracts, technical specifications, and academic papers.
**PageIndex retrieval uses LLM reasoning, not vectors**
Tree Search retrieval does not use vector similarity. It sends the document's structural outline (titles + summaries, no text) to an LLM and asks it to identify the most relevant sections. Each retrieval call therefore incurs LLM cost and latency, typically 1–3 seconds per query. For high-throughput, latency-sensitive workloads, ChunkEmbed with hybrid search is more appropriate.
```python Python theme={null}
# Create a KB with PageIndex for structured document retrieval
response = requests.post(
f"{BASE_URL}/api/knowledge-bases",
headers=headers,
json={
"name": "Compliance Manual",
"indexing_config": {
"strategy": "page_index",
"extra": {
"model": "gpt-4o",
"if_add_node_summary": "yes",
},
},
"retrieval_config": {
"method": "tree_search",
"top_k": 5,
},
},
)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Compliance Manual",
indexing_config: {
strategy: "page_index",
extra: {
model: "gpt-4o",
if_add_node_summary: "yes",
},
},
retrieval_config: {
method: "tree_search",
top_k: 5,
},
}),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Compliance Manual",
"indexing_config": {
"strategy": "page_index",
"extra": {"model": "gpt-4o", "if_add_node_summary": "yes"}
},
"retrieval_config": {"method": "tree_search", "top_k": 5}
}'
```
### GraphIndex
GraphIndex builds on PageIndex with two additional stages: cross-reference enrichment and node embedding. In stage one, it runs the same PageIndex pipeline to build the hierarchical document tree. In stage two, an LLM analyzes each node's text against the full table of contents and identifies which other sections the node explicitly references: citations, mentions, dependencies, or cross-references (not structural parent/child relationships). These references are stored in each node's metadata. In stage three, each node's title and summary (plus its reference list) are embedded into a vector, and a BM25 sparse index is built over node text.
The key advantage over plain PageIndex is retrieval flexibility. Because nodes have embeddings and a BM25 index, GraphIndex knowledge bases support vector search, hybrid search, and full-text search: the same fast retrieval methods as ChunkEmbed, but over structured document sections instead of arbitrary chunks. After the initial retrieval, graph expansion automatically pulls in first-degree referenced nodes (sections that the matched sections explicitly cite), enriching results with related context. This makes GraphIndex suited for documents with dense internal references, such as regulatory frameworks, technical standards, and codebases with cross-module dependencies.
**GraphIndex is expensive to index, but cheap to retrieve**
GraphIndex performs all the LLM work of PageIndex (tree building, node splitting, summary generation) plus one additional LLM call per node for cross-reference detection, plus embedding computation for every node. For a document with 50 sections, that means \~50 extra LLM calls on top of the PageIndex work. Enrichment concurrency is capped at 7 by default. Retrieval, by contrast, is fast and cheap: vector, hybrid, or full-text search over node embeddings, with no LLM calls at query time. Use GraphIndex when you want structural awareness during indexing with fast, scalable retrieval.
```python Python theme={null}
# Create a KB with GraphIndex for cross-referenced document retrieval
response = requests.post(
f"{BASE_URL}/api/knowledge-bases",
headers=headers,
json={
"name": "Regulatory Framework",
"indexing_config": {
"strategy": "graph_index",
"extra": {
"model": "gpt-4o",
"enrichment_model": "gpt-4o",
"embedding_model": "text-embedding-3-small",
"if_add_node_summary": "yes",
},
},
"retrieval_config": {
"method": "hybrid",
"top_k": 10,
},
},
)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Regulatory Framework",
indexing_config: {
strategy: "graph_index",
extra: {
model: "gpt-4o",
enrichment_model: "gpt-4o",
embedding_model: "text-embedding-3-small",
if_add_node_summary: "yes",
},
},
retrieval_config: {
method: "hybrid",
top_k: 10,
},
}),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Regulatory Framework",
"indexing_config": {
"strategy": "graph_index",
"extra": {
"model": "gpt-4o",
"enrichment_model": "gpt-4o",
"embedding_model": "text-embedding-3-small",
"if_add_node_summary": "yes"
}
},
"retrieval_config": {"method": "hybrid", "top_k": 10}
}'
```
### Doc2JSON
Doc2JSON extracts structured data from documents using a sliding-window LLM approach. You define a JSON schema with the fields you want to extract (names, types, descriptions, examples), and the platform slides a window across the document content. For each window, an LLM extracts a brief summary and fills in schema fields from the visible text. Extractions are merged across windows: scalar fields use last-value-wins, arrays accumulate new items, and objects are deep-merged. After all windows are processed, a final LLM call generates a combined document summary, which is embedded for vector retrieval.
Doc2JSON supports two modes. Text mode (default) processes extracted text using token-based windows (default 4000 tokens, 200 overlap). Image mode processes page screenshots directly as multimodal content, which helps with documents whose complex layouts, tables, or forms lose formatting under text extraction. In image mode, pages are grouped into windows (default 3 pages per window) and sent as images to the LLM.
```python Python theme={null}
# Create a KB with Doc2JSON for invoice extraction
response = requests.post(
f"{BASE_URL}/api/knowledge-bases",
headers=headers,
json={
"name": "Invoice Extraction",
"indexing_config": {
"strategy": "doc2json",
"extra": {
"json_schema": {
"fields": [
{"name": "vendor_name", "type": "string", "description": "Company that issued the invoice"},
{"name": "invoice_date", "type": "string", "description": "Date of the invoice"},
{"name": "total_amount", "type": "number", "description": "Total amount due"},
{"name": "line_items", "type": "array", "description": "Individual line items",
"item_type": "object", "items": {
"type": "object", "fields": [
{"name": "description", "type": "string"},
{"name": "quantity", "type": "integer"},
{"name": "unit_price", "type": "number"},
]
}},
]
},
"extraction_model": "gpt-4o",
},
},
},
)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Invoice Extraction",
indexing_config: {
strategy: "doc2json",
extra: {
json_schema: {
fields: [
{ name: "vendor_name", type: "string", description: "Company that issued the invoice" },
{ name: "invoice_date", type: "string", description: "Date of the invoice" },
{ name: "total_amount", type: "number", description: "Total amount due" },
{ name: "line_items", type: "array", description: "Individual line items",
item_type: "object", items: {
type: "object", fields: [
{ name: "description", type: "string" },
{ name: "quantity", type: "integer" },
{ name: "unit_price", type: "number" },
]
}},
]
},
extraction_model: "gpt-4o",
},
},
}),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Invoice Extraction",
"indexing_config": {
"strategy": "doc2json",
"extra": {
"json_schema": {
"fields": [
{"name": "vendor_name", "type": "string", "description": "Company that issued the invoice"},
{"name": "total_amount", "type": "number", "description": "Total amount due"}
]
},
"extraction_model": "gpt-4o"
}
}
}'
```
## Retrieval Strategies
The retrieval strategy controls how queries find relevant content within a knowledge base. You set the retrieval method when creating a KB or when calling the search endpoint. The right choice depends on your indexing strategy, query patterns, latency requirements, and budget.
| Method | How It Works | Latency | Cost | Best For |
| -------------------- | -------------------------------------------------------------------------------- | ------------------ | ---------------------- | ----------------------------------------------------------------- |
| vector\_search | Embeds the query and finds nearest vectors via cosine similarity in pgvector | Very low (\~100ms) | Low (one embed call) | Semantic matching — captures meaning even without shared keywords |
| full\_text | BM25 keyword scoring with stemming via PostgreSQL tsvector | Low | None (no API calls) | Exact phrases, product names, error codes, IDs, proper nouns |
| hybrid (recommended) | Runs vector + BM25 in parallel, fuses results with Reciprocal Rank Fusion (k=60) | Low | Low (one embed call) | Production RAG — robust across query types |
| tree\_search | LLM selects documents, then selects sections by reasoning over ToC structure | Medium (1–3s) | Medium (two LLM calls) | PageIndex KBs only — complex structural queries |
### Vector Search
Vector search embeds the query using the same model as indexing, then finds the most similar chunk embeddings via cosine similarity in pgvector. It captures semantic meaning: "How do I reset my credentials?" will match chunks about password resets even without shared keywords. Results are ranked by similarity score (higher = more relevant). An optional similarity\_threshold filters out low-quality matches.
### Full-Text Search (BM25)
Full-text search uses BM25 scoring, a keyword relevance algorithm that considers term frequency, document length, and inverse document frequency. Terms are stemmed using PostgreSQL's English dictionary (to\_tsvector), so "running" matches "run". BM25 uses standard parameters: k1=1.2 for term frequency saturation and b=0.75 for length normalization. No API calls are needed; scoring runs entirely in PostgreSQL. This complements vector search by catching results that share keywords but may not be semantically close in embedding space.
### Hybrid Search (Recommended)
Hybrid search runs both vector search and full-text search in parallel, then fuses the results using Reciprocal Rank Fusion (RRF). RRF merges ranked lists without needing to normalize incompatible score ranges; it uses rank positions, not scores. The formula: rrf\_score(d) = sum of weight / (k + rank) across all lists, with k=60 (the original RRF paper constant). The vector\_weight parameter (default 0.5) balances the two signals: higher values favor semantic matches, lower values favor keyword matches. Results are normalized so the top result has score 1.0.
```python Python theme={null}
# Create a KB with hybrid search retrieval
response = requests.post(
f"{BASE_URL}/api/knowledge-bases",
headers=headers,
json={
"name": "Product Docs",
"indexing_config": {
"strategy": "chunk_embed",
"chunk_size": 2000,
"overlap": 50,
},
"retrieval_config": {
"method": "hybrid",
"top_k": 10,
"vector_weight": 0.6,
},
},
)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Product Docs",
indexing_config: {
strategy: "chunk_embed",
chunk_size: 2000,
overlap: 50,
},
retrieval_config: {
method: "hybrid",
top_k: 10,
vector_weight: 0.6,
},
}),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Product Docs",
"indexing_config": {"strategy": "chunk_embed", "chunk_size": 2000, "overlap": 50},
"retrieval_config": {"method": "hybrid", "top_k": 10, "vector_weight": 0.6}
}'
```
### Tree Search
Tree Search is a two-phase LLM-driven retrieval method for PageIndex and GraphIndex knowledge bases. In phase one, the LLM reviews compact summaries of each indexed document (name, description, top-level section titles) and selects which documents are relevant. In phase two, the LLM examines the selected documents' full ToC structure (section titles and summaries, no full text) and identifies the most relevant sections, returning up to top\_k node IDs. The platform then fetches the full text of those sections from the database.
For multi-document KBs, node IDs are globally prefixed (e.g., d0:0001, d1:0005) so the LLM can reference sections across documents. Response parsing is robust: it tries JSON first, then falls back to regex pattern matching, and validates all returned IDs against the actual tree structure to prevent hallucinated references.
**Tree Search requires PageIndex**
Tree Search reads from the page\_index\_toc and page\_index\_nodes tables. It is only compatible with the PageIndex strategy. GraphIndex uses vector/hybrid/full-text search over node embeddings instead.
## Reranking
Reranking is an optional second stage that improves retrieval precision. The initial retrieval (vector, hybrid, or full-text) fetches a broad candidate pool, by default 20 items (the `candidate_count` parameter). A cross-encoder reranker then re-scores each candidate by evaluating the query-document pair jointly. Cross-encoders are more accurate than bi-encoder embeddings because they see the query and document together, but they can't be used for initial retrieval because they don't produce storable vectors. After reranking, the `top_k` results are returned to the caller.
Reranking is a property of the knowledge base, configured under `retrieval_config.reranker`. It is **enabled whenever `reranker.model` is set** and disabled when the `reranker` object is absent. Because it lives on the KB, you can set it at creation time or change it later with `PATCH /api/knowledge-bases/{id}`. No reindex is needed, since reranking happens at query time.
```json theme={null}
"retrieval_config": {
"method": "hybrid",
"top_k": 5,
"reranker": {
"model": "cohere/rerank-english-v3.0",
"candidate_count": 20
}
}
```
| Field | Type | Default | Notes |
| ----------------- | ------ | ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `model` | string | `cohere/rerank-english-v3.0` | Reranker model identifier. Presence of this field is what enables reranking. |
| `candidate_count` | int | `20` | How many candidates Stage 1 fetches before reranking down to `top_k`. Higher = better recall, more latency. |
The reranker is applied as a strict two-stage pipeline: Stage 1 retrieves `candidate_count` items using the configured `method`, Stage 2 re-scores them and truncates to `top_k`. If the reranker call fails, retrieval falls back to the Stage 1 results truncated to `top_k` (fail-open). Each rerank is billed as a `reranker_call`.
| Reranker model | Provider | Notes |
| -------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------- |
| `cohere/rerank-english-v3.0` (default) | Cohere | High quality, English-optimized |
| `cohere/rerank-multilingual-v3.0` | Cohere | Multilingual support |
| `jina_ai/jina-reranker-v2-base-multilingual` | Jina AI | Multilingual, competitive quality |
| `voyage/rerank-2.5` | Voyage | Strong general-purpose reranker |
| `voyage/rerank-2.5-lite` | Voyage | Lighter variant, lower cost |
| `zerank-1` / `zerank-2` | ZeroEntropy | Dedicated reranking models (model strings starting with `zerank` route to the ZeroEntropy client) |
Other LiteLLM-compatible rerankers (Together AI, Azure AI, self-hosted VLLM via a `hosted_vllm/` prefix) also work. Always treat `GET /api/config/kb-defaults` as the authoritative list of selectable rerankers rather than hardcoding this table.
**Reranker API keys are platform-managed**
Reranker API keys (Cohere, Jina, Voyage, ZeroEntropy) are configured at the platform level by your administrator, not per-organization. If reranking returns errors, contact your platform admin to verify the reranker provider key is configured.
## Query Enrichment
Query enrichment is an optional LLM step that rewrites the user's raw query before retrieval to improve recall. It is a property of the knowledge base, configured under `retrieval_config.query_enrichment`, and is **off by default**. You opt in per KB.
```json theme={null}
"retrieval_config": {
"method": "hybrid",
"query_enrichment": {
"enabled": true,
"model": "gpt-5-mini"
}
}
```
| Field | Type | Default | Notes |
| --------- | ------- | ------------ | ---------------------------------------------- |
| `enabled` | boolean | `false` | Turns LLM query enrichment on for this KB. |
| `model` | string | `gpt-5-mini` | LLM used to rewrite the query (temperature 0). |
When enabled, the LLM rewrites each query into two variants and retrieval uses whichever fits each signal:
* **`enriched_query`**: a semantically rich restatement, used for the vector-search embedding.
* **`keyword_query`**: OR-joined keywords and synonyms, used for BM25 full-text scoring.
If a session history is available (e.g. an agent conversation), the last few turns are passed in so the LLM can resolve pronouns and ellipsis, turning a follow-up like "what about pricing?" into "What are the AWS cloud pricing options?". The enrichment result is returned in the search/context response under `query_enrichment` (`original_query`, `enriched_query`, `keyword_query`, `model`, `method: "llm_enrichment"`), so you can inspect exactly what was searched. Each enrichment is billed as a `query_enrichment` action.
**When to enable query enrichment**
Enable it for **conversational / multi-turn** retrieval where follow-up queries depend on prior context, or when terse keyword queries benefit from synonym expansion on hybrid/full-text KBs. Skip it for simple single-shot lookups; it adds an LLM call (latency + cost) to every search. It is **automatically skipped for `tree_search`** KBs, which already do their own LLM-based document/section selection. For `full_text` and `hybrid`, even when LLM enrichment is off, a fast tokenization-based context builder still folds recent conversation into the keyword query. That is not the same as (nor billed like) LLM enrichment.
## Multimodal Retrieval
By default, retrieval returns the **text** of matched chunks. Setting `retrieval_config.context_mode` to `"image"` switches a knowledge base into multimodal retrieval: instead of (or alongside) text, the platform attaches the **original page images** of the matched content and passes them to the LLM as multimodal content blocks. This preserves layout, tables, charts, stamps, and handwriting that text extraction flattens or loses.
```json theme={null}
"retrieval_config": {
"method": "hybrid",
"top_k": 5,
"context_mode": "image"
}
```
| Field | Type | Default | Notes |
| ---------------- | ------ | --------------------------- | ----------------------------------------------------------------------------------------------- |
| `context_mode` | string | `text` | `text` returns extracted text; `image` returns page-image content blocks for the matched items. |
| `image_delivery` | string | platform default (`base64`) | How images are delivered — inline `base64` data URIs or signed URLs. |
`context_mode` is a **retrieval-time** option and is available for **all indexing strategies except Doc2JSON**: `chunk_embed`, `full_document`, `page_index`, and `graph_index` all support it. (Doc2JSON has its own indexing-time `use_images` option instead; see its section above.) It works by resolving the page-image derivatives that were rendered when the source was extracted, so it requires those page images to exist. If a matched item has no page image available, that item falls back to its text. Like the other retrieval settings, `context_mode` lives on the KB record and can be set at creation or changed later via `PATCH` without reindexing.
**Multimodal retrieval needs a vision-capable agent model**
The matched page images are only useful if the consuming model can see images. When you attach a multimodal KB to an agent, give the agent a vision-capable model (e.g. a GPT-4o / GPT-5 class model). A text-only model will silently ignore the image blocks, a common cause of an agent that "has the document" but answers as if it didn't.
## Embedding Models
Embeddings convert text into high-dimensional vectors that capture semantic meaning. The platform uses OpenAI's text-embedding-3-small by default (1536 dimensions). All chunks in a knowledge base must use the same embedding model, so if you change the model, you must reindex. Embedding calls are batched at up to 250,000 tokens per API call for efficiency. The platform supports embedding models from multiple providers via LiteLLM; select your preferred model in Settings > Knowledge Indexing.
| Model | Provider | Dimensions | Tradeoff |
| -------------------------------------------------------- | --------- | ---------- | -------------------------------------------------------------------------- |
| text-embedding-3-small (default) | OpenAI | 1536 | Best balance of quality, cost, and speed. Fits within HNSW index limit. |
| text-embedding-3-large | OpenAI | 3072 | Higher quality, 2x storage. Exceeds HNSW dimension limit — see note below. |
| text-embedding-ada-002 | OpenAI | 1536 | Legacy model — use text-embedding-3-small instead. |
| embed-english-v3.0 | Cohere | 1024 | High-quality English embeddings. Fits within HNSW limit. |
| embed-multilingual-v3.0 | Cohere | 1024 | Multilingual support across 100+ languages. |
| embed-english-light-v3.0 / embed-multilingual-light-v3.0 | Cohere | 384 | Lightweight variants — faster and cheaper, lower quality. |
| voyage/voyage-01 | Voyage AI | 1024 | Strong general-purpose embeddings from Voyage AI. |
| gemini/text-embedding-004 | Google | 768 | Google Gemini embedding model. |
| mistral/mistral-embed | Mistral | 1024 | Mistral AI embedding model. |
**Embedding provider API keys**
OpenAI embeddings use the platform-managed OPENAI\_API\_KEY. For other providers (Cohere, Voyage, Mistral, Google), the corresponding API key environment variable (e.g. COHERE\_API\_KEY, VOYAGE\_API\_KEY, MISTRAL\_API\_KEY) must be configured at the platform level by your administrator before creating projects. These keys are passed through to LiteLLM at runtime. Contact your platform admin if a non-OpenAI embedding model returns authentication errors.
**HNSW index dimension limit**
The platform uses pgvector HNSW indexes for fast approximate nearest-neighbor search. HNSW indexes support a maximum of 2000 dimensions. The default model text-embedding-3-small (1536 dimensions) fits within this limit and gets full HNSW acceleration. Models with more than 2000 dimensions (like text-embedding-3-large at 3072) fall back to sequential scan: still correct, but significantly slower for large knowledge bases.
## Searching a Knowledge Base
Once indexed, you can search a knowledge base with any natural language query. The search uses whichever retrieval method was configured on the KB, or you can override it per-request. Results include the matched text, relevance scores, and source metadata.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/knowledge-bases/{kb_id}/search",
headers=headers,
json={"query": "How do I reset my password?", "top_k": 5},
)
results = response.json()
for chunk in results["results"]:
print(f"Score: {chunk['score']:.3f}")
print(chunk["text"][:200])
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/search`, {
method: "POST",
headers,
body: JSON.stringify({ query: "How do I reset my password?", top_k: 5 }),
});
const { results } = await res.json();
results.forEach((chunk: any) => {
console.log(`Score: ${chunk.score.toFixed(3)}`);
console.log(chunk.text.slice(0, 200));
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{kb_id}/search' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"query": "How do I reset my password?", "top_k": 5}'
```
## Reindexing
**Reindexing replaces all indexed content**
When you reindex a knowledge base, all existing chunks, tree nodes, or extracted JSON are deleted and recreated from scratch. The KB remains searchable during reindexing but results may be incomplete until it finishes. For large KBs with PageIndex or GraphIndex, reindexing can take significant time and LLM tokens.
## Recommended Configurations
| Use Case | Indexing | Retrieval | Notes |
| -------------------------- | ------------------------------------ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| General RAG (default) | ChunkEmbed (2000 tokens, 50 overlap) | Hybrid Search | Works for most documents. Add a reranker for higher precision. |
| Whole-document retrieval | Full Document | Hybrid Search (top\_k=3) | Each match returns the entire document text. Best for collections of short, self-contained documents — case law, articles, memos. |
| Long structured PDFs | PageIndex | Tree Search | Compliance, legal, technical specs. Higher cost but superior structure-aware retrieval. |
| Cross-referenced documents | GraphIndex | Hybrid Search | Regulations, standards. Vector/hybrid search over node embeddings with automatic graph expansion of referenced sections. |
| Keyword-heavy content | ChunkEmbed | Full-Text Search | Logs, code, error messages. BM25 excels at exact matches without embedding cost. |
| Invoice / form extraction | Doc2JSON | Vector Search | Define a schema and extract structured fields from documents. |
## Project-Level Defaults
You can configure project-wide defaults for all indexing and retrieval parameters in Settings > Knowledge Indexing and Settings > Knowledge Retrieval. These defaults apply to newly created knowledge bases unless overridden in the indexing\_config or retrieval\_config at creation time. Settings include chunk sizes, embedding models, LLM models for PageIndex and GraphIndex, reranker configuration, and many advanced tuning parameters.
## Next Steps
Step-by-step guide to creating and indexing a KB.
Attach a KB to an agent for RAG-powered conversations.
Full endpoint documentation.
# Observability
Source: https://docs.powabase.ai/concepts/observability
What's user-callable for monitoring your project's health, and what stays internal to the platform infrastructure. Honest framing of where the line is drawn today.
Powabase's observability surface today is intentionally minimal at the user-callable layer. Most platform-side telemetry (request histograms, per-pod metrics, error rates, latency percentiles) lives behind the platform's internal monitoring stack and isn't exposed at your project URL. This page documents what *is* user-accessible, what's coming from the Studio side, and what stays internal.
For service-level health, see the [Status page](https://status.powabase.ai). For per-resource observability inside the AI surface (run logs, execution histories), see the relevant API reference pages.
## What you can call
### GET /api/health
The one public health endpoint. Returns 200 OK when the project's API service is reachable through Kong. Lightweight, useful as a synthetic uptime check.
```bash theme={null}
curl 'https://{ref}.p.powabase.ai/api/health'
# → 200 {"status": "healthy", "service": "project-service"}
```
No auth required. Don't poll faster than once every 30 seconds; the endpoint is meant for monitoring, not stress testing.
That's the entire public observability API surface.
## What the Studio shows you
The Studio at [app.powabase.ai](https://app.powabase.ai) has three observability views for each project, fed by control-plane endpoints (`/api/platform/projects/[/observability/*`) that aren't exposed externally:
* **Project overview:** aggregate request counts, error rates, recent activity timeline.
* **Extraction queue:** for the AI surface specifically, which source extractions are pending / running / failed.
* **Health checks:** per-service status (Postgres, GoTrue, Storage, etc.) inside your project's stack.
These are Studio-facing views, not API endpoints you can programmatically query against your project URL. The data feeding them comes from Prometheus metrics scraped from each service's internal `/metrics` endpoint, but those `/metrics` endpoints are not routed through Kong and not accessible from outside the cluster.
## What's NOT externally accessible
Honest list, because expectations matter:
* **No `/metrics` Prometheus endpoint** on your project URL. The project API ships `prometheus_flask_exporter` internally but the route isn't in Kong's allowlist. Self-hosted deployments can change this; managed cloud doesn't.
* **No structured request log API.** If you want to know "what requests did my project receive in the last 24 hours?", the answer today is "check the Studio's overview page." There's no programmatic export.
* **No tail-style log streaming.** No `/logs/follow` WebSocket, no log shipping endpoint. Workflow execution logs are queryable via `/api/workflows/{id}/executions/{execution_id}/logs` (see [Workflows reference](/api-reference/workflows)) but that's the only piece.
* **No per-endpoint latency histograms exposed.** Internal histograms exist (Prometheus-style) but aren't user-callable.
If your use case needs production-grade observability of your Powabase project (alerting on error rate spikes, tracing requests across services, etc.), the realistic path today is:
1. **Hit `/api/health` from your own monitoring tool.** Datadog Synthetics, BetterUptime, or anything that pings URLs on a schedule.
2. **Instrument your own application.** The auth/storage/PostgREST calls go through your code; you can wrap them in metrics emission to whatever you already use.
3. **Use the agentic API's existing logs.** Run histories, execution logs, and session messages are all queryable per resource.
## Per-resource observability inside the API
The agentic `/api/*` surface has logging for AI resources:
* **`GET /api/sources/{id}`:** extraction status, error message if extraction failed, task id.
* **`GET /api/knowledge-bases/{id}/sources?status=failed`:** list failed indexed sources to find broken indexing runs.
* **`GET /api/agents/runs/{run_id}`:** full run state including LLM steps, tool calls, retrieved context.
* **`GET /api/sessions/{id}/runs/{run_id}/retrieved-context`:** what context the agent saw on a specific run, useful for debugging "why did it answer that way".
* **`GET /api/workflows/{id}/executions/{execution_id}/logs`:** per-block logs from a workflow execution.
These are the right place to look for "why did this specific operation fail" debugging. They don't aggregate across operations; for that, query directly against `ai.*` via PostgREST. See [ai-schema recipes Recipe 2](/guides/ai-schema-recipes#recipe-2--usage-analytics-on-agent_runs) for the usage-analytics pattern.
## Status and incidents
Platform-wide incidents go on [status.powabase.ai](https://status.powabase.ai). Subscribe there for outage notifications.
Per-project status (your specific project is healthy / degraded) isn't separately reported today. The assumption is that incidents affect everyone, and the platform's monitoring catches per-project issues before users do.
If you suspect your project is in a degraded state and the platform status page is green, the right move is to check `/api/health` from a couple of different network locations. Persistent failures: file a support ticket with your project ref.
## What's coming
A programmatic observability API surface is on the platform team's radar but not committed. The most likely next addition is an exportable request-and-error log feed, similar to what the Studio's overview shows but accessible from your own monitoring tools. If you have a specific observability need, file it as a platform request; the more concrete the use case, the higher it ranks.
## Next steps
The other "platform internal" topic with similar disclosure shape.
What users get wrong that ends up looking like an observability problem.
Where the closest thing to "query my project's telemetry" actually lives: direct queries against ai.\*.
If you self-host, you control what's exposed; managed cloud is more conservative.
# Multi-Agent Orchestration
Source: https://docs.powabase.ai/concepts/orchestrations-concept
Orchestrations coordinate multiple agents to solve complex tasks. Three execution strategies (Supervisor, Sequential, and Parallel) give you different patterns for multi-agent collaboration, from autonomous delegation to pipeline processing to concurrent fan-out with merged results.
## What is an Orchestration?
An Orchestration is a container that groups multiple agents (entities) and runs them using a coordination strategy. Each entity agent has its own system prompt, tools, and knowledge bases, and the orchestration handles how they interact. You choose a strategy that matches your use case: Supervisor for autonomous delegation, Sequential for pipeline processing, or Parallel for concurrent execution with merged results.
## Execution Strategies
| Strategy | Pattern | How It Works | Best For |
| ---------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| supervisor | Coordinator delegates | Multi-domain support (billing + tech + sales), complex routing, dynamic task decomposition | |
| sequential | Pipeline | Entity agents run in order (sorted by position). Each agent receives the previous agent's output as its input. The final agent's output is the orchestration result. | Multi-stage processing: extract → analyze → summarize → format |
| parallel | Fan-out + merge | All entity agents run concurrently on the same input. If multiple agents produce results, a merge agent (gpt-4.1-mini) combines them into a single coherent response. | Independent analysis from multiple perspectives, parallel research tasks |
### Supervisor Strategy
The Supervisor strategy creates a coordinator agent that has access to a delegation tool for each entity agent. When a user message arrives, the coordinator reasons about which entity should handle it and calls the appropriate delegate\_to\_ tool with a task description. The entity agent runs with its own tools and knowledge bases (up to 10 ReAct steps by default) and returns its result. The coordinator can delegate to several entities in sequence, or feed the result of one delegation into the next. Its own ReAct loop runs for up to 25 steps.
]
Each delegation creates a child execution context with an incremented depth (max depth: 3, preventing infinite recursive delegation). The child context gets a budget allocation from the parent's remaining token budget. Entity agents share the parent's abort signal, so cancelling the orchestration cancels all active entity runs.
The coordinator's system prompt is auto-generated from the entity role descriptions. Write specific, non-overlapping role descriptions to help the coordinator make clear routing decisions. "Handles billing inquiries, invoices, and payment issues" routes better than "Handles customer questions."
### Sequential Strategy
The Sequential strategy runs entity agents one after another in position order. The first agent receives the user's message as input. Each subsequent agent receives the previous agent's output as its input. If any agent in the chain fails, the entire orchestration fails immediately. This is ideal for multi-stage processing pipelines where each stage transforms or enriches the data.
**Example: Document processing pipeline**
Agent 1 (Extractor): extracts key facts from a document. Agent 2 (Analyzer): identifies risks and opportunities from the extracted facts. Agent 3 (Writer): produces a formatted executive summary from the analysis.
### Parallel Strategy
The Parallel strategy runs all entity agents concurrently on the same input using a thread pool. Each agent processes the user's message independently with its own tools and knowledge bases. If there is only one entity, its output is returned directly. If there are multiple entities, after all agents complete, a merge agent (gpt-4.1-mini by default, configurable via orchestration settings) combines their outputs into a single coherent response. If any agent fails, the entire orchestration fails.
## Entity Configuration
Each entity in an orchestration has a role description and optional configuration. The role description is critical for the Supervisor strategy, since it tells the coordinator what the entity specializes in. For Sequential and Parallel, the role is descriptive metadata. Entity config can override max\_steps (default 10) to control how many ReAct iterations the entity agent runs.
```python Python theme={null}
# Create an orchestration with the supervisor strategy
response = requests.post(
f"{BASE_URL}/api/orchestrations",
headers=headers,
json={
"name": "Customer Support Team",
"strategy": "supervisor",
},
)
orch = response.json()
orch_id = orch["id"]
# Add entity agents with clear role descriptions
requests.post(
f"{BASE_URL}/api/orchestrations/{orch_id}/entities",
headers=headers,
json={
"agent_id": billing_agent_id,
"role": "Handles billing inquiries, invoices, payments, and refund requests",
},
)
requests.post(
f"{BASE_URL}/api/orchestrations/{orch_id}/entities",
headers=headers,
json={
"agent_id": tech_agent_id,
"role": "Handles technical issues, API errors, integration problems, and setup questions",
},
)
```
```typescript TypeScript theme={null}
// Create an orchestration with the supervisor strategy
const orchRes = await fetch(`${BASE_URL}/api/orchestrations`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Customer Support Team",
strategy: "supervisor",
}),
});
const orch = await orchRes.json();
// Add entity agents with clear role descriptions
await fetch(`${BASE_URL}/api/orchestrations/${orch.id}/entities`, {
method: "POST",
headers,
body: JSON.stringify({
agent_id: billingAgentId,
role: "Handles billing inquiries, invoices, payments, and refund requests",
}),
});
await fetch(`${BASE_URL}/api/orchestrations/${orch.id}/entities`, {
method: "POST",
headers,
body: JSON.stringify({
agent_id: techAgentId,
role: "Handles technical issues, API errors, integration problems, and setup questions",
}),
});
```
```bash cURL theme={null}
# Create orchestration
curl -X POST '{BASE_URL}/api/orchestrations' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "Customer Support Team", "strategy": "supervisor"}'
# Add billing agent entity
curl -X POST '{BASE_URL}/api/orchestrations/{orch_id}/entities' \
-H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"agent_id": "{billing_agent_id}", "role": "Handles billing inquiries, invoices, and payments"}'
```
## Streaming Events
Orchestration streaming exposes the full execution flow via SSE. For the Supervisor strategy, you see delegation events as the coordinator routes tasks to entities. For Sequential, you see step events as each agent runs. For Parallel, events from concurrent agents may interleave.
| Event | Description |
| ------------------------- | --------------------------------------------------------------- |
| start | Orchestration run started — includes run\_id and session\_id |
| orchestration\_started | Execution has begun with the configured strategy |
| delegation\_started | Supervisor: coordinator is delegating a task to an entity agent |
| delegation\_completed | Supervisor: entity agent finished its subtask |
| sequential\_step | Sequential: an agent in the pipeline has started/completed |
| tool\_call / tool\_result | An entity agent is calling/receiving a tool |
| chunk | Text chunk from the final response |
| complete | Orchestration run finished — includes content, usage, steps |
| error | An error occurred during execution |
## Limits
| Constraint | Default | Notes |
| ----------------------- | ------------ | -------------------------------------------------------------------------------- |
| Coordinator max steps | 25 | Supervisor strategy: how many ReAct steps the coordinator gets |
| Entity max steps | 10 | Per-entity ReAct step limit, configurable in entity config |
| Max orchestration depth | 3 | Prevents recursive delegation loops (coordinator → entity → sub-delegation) |
| Parallel merge model | gpt-4.1-mini | Configurable via orchestration settings. Used to combine parallel agent outputs. |
## Next Steps
Create an orchestration and run it step by step.
Understand the ReAct loop and tool system that powers each entity.
For deterministic multi-step pipelines, use workflows instead.
Full endpoint documentation.
# Platform Comparison
Source: https://docs.powabase.ai/concepts/platform-comparison
How Powabase compares to popular AI frameworks, RAG services, workflow tools, and backend platforms. Where each tool excels, and how Powabase's unified approach differs.
## The Problem with Assembling Your Own Stack
Building a production AI application typically requires stitching together 5–7 separate tools: a vector database for RAG, an agent framework for tool calling, a workflow engine for automation, an LLM gateway for model routing, an auth system for users, a file storage service for documents, and a database for application state. Each tool has its own API, deployment model, and failure modes. Powabase replaces this entire assembly with a single REST API: one endpoint, one auth model, one database, one deployment.
## Comparison Overview
## vs Supabase
Supabase is a general-purpose backend-as-a-service (Postgres, auth, storage, real-time) that Powabase actually builds on: each project's infrastructure uses Supabase components. The difference is purpose. Supabase provides the database and infrastructure primitives common to most SaaS apps, while Powabase adds a set of prebuilt agentic abstractions on top to speed up development of AI-native applications.
| Capability | Powabase | Supabase |
| ------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Database | Postgres + pgvector per project (included) | Postgres + pgvector (included) |
| Auth & Storage | GoTrue + Storage API per project (included) | GoTrue + Storage API (included) |
| Document ingestion | Upload API → automatic extraction (PDF, DOCX, images w/ OCR) | No — build your own extraction pipeline |
| RAG pipeline | 5 indexing strategies, 4 retrieval methods, reranking, chunking | pgvector similarity search only — chunking, embedding, and retrieval pipeline are DIY |
| Agent framework | ReAct loop, 8 builtin tools, custom tools, MCP, hooks, approval flow | No agent framework — integrate LangChain or similar |
| Multi-agent orchestration | Supervisor, Sequential, Parallel strategies | None |
| Workflow automation | DAG-based workflows with webhooks, schedules, AI Copilot builder | Edge Functions (serverless compute, no workflow engine) |
| Streaming | SSE for agents, orchestrations, and workflows with event lifecycle | Real-time subscriptions (row-level changes, not AI events) |
Choose Supabase when you need a general-purpose backend without AI features. Reach for Powabase when your application's core value is AI-powered: you get everything Supabase offers (Postgres, auth, storage, PostgREST) plus a complete AI abstraction layer.
## vs LangChain / LangGraph
LangChain is the most popular AI framework, and LangGraph extends it with graph-based agent orchestration and durable state. They provide rich abstractions for building AI applications, but they are frameworks, not infrastructure. You write code using their libraries, then deploy and operate everything yourself.
| Capability | Powabase | LangChain / LangGraph |
| ------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Deployment model | Managed API — no infrastructure to operate | Framework — you deploy, scale, and monitor your own services |
| Database | Postgres + pgvector included per project | None — bring your own (Pinecone, Weaviate, pgvector, etc.) |
| Auth | GoTrue included per project | None — build your own auth layer |
| Document ingestion | Upload API with automatic extraction | Document loaders (community-maintained, varying quality) |
| RAG | 5 indexing strategies, 4 retrieval methods, managed pipeline | Components for assembly — you build and maintain the pipeline |
| Agent framework | Managed ReAct loop with streaming SSE, tools, hooks, approval | LangGraph agents with durable state, checkpointing, time-travel debugging |
| Multi-agent | 3 orchestration strategies via API | Graph-based agent coordination (flexible but complex) |
| Observability | Run history, events, and usage stored per session | LangSmith (paid, starts at \$39/user/month) |
| Workflow automation | Visual + API workflow builder with triggers and scheduling | LangGraph workflows (code-defined, no visual builder in OSS) |
Choose LangChain/LangGraph when you need maximum flexibility, custom execution models, or durable agent state with time-travel debugging. Powabase fits better when you want a production-ready API without managing infrastructure, especially if you also need auth, storage, and a database alongside your AI features.
## vs Agno
Agno (formerly Phidata) is a lightweight Python agent framework with built-in agentic RAG and multi-agent teams. It emphasizes simplicity and speed: agents are pure Python objects, not graphs or chains. AgentOS provides a monitoring and management control plane.
| Capability | Powabase | Agno |
| ------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Deployment model | Managed API with per-project isolation | Framework — you deploy agents in your own infrastructure |
| Database & Auth | Postgres + pgvector + GoTrue included | None — bring your own database and auth |
| RAG pipeline | 5 indexing strategies, 4 retrieval methods, managed document ingestion | Agentic RAG with hybrid search and reranking — but you manage the vector DB |
| Agent framework | Managed ReAct with hooks, approval flow, session persistence | Lightweight agents with tool calling and memory |
| Multi-agent | 3 strategies (Supervisor, Sequential, Parallel) via API | Teams with role-based collaboration |
| Workflow automation | DAG workflows with webhooks, schedules, AI Copilot | Agno Workflows (code-defined sequential/parallel) |
| MCP support | Runtime tool discovery via MCP servers | MCP server connections supported |
| Management | Built-in dashboard, settings, per-project config | AgentOS control plane (monitoring, playground) |
Choose Agno when you want a lightweight Python framework and are comfortable managing your own infrastructure. Powabase is the better fit when you want a fully managed backend with database, auth, storage, and AI, all accessible via REST API from any language.
## vs Vectara
Vectara is a fully managed RAG-as-a-Service platform with strong document processing, hybrid search, and built-in hallucination detection. It's the strongest pure-RAG competitor, but it's RAG-only.
| Capability | Powabase | Vectara |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| RAG pipeline | 5 indexing strategies (ChunkEmbed, Full Document, PageIndex, GraphIndex, Doc2JSON), 4 retrieval methods, reranking | ML-based chunking, hybrid search (neural + lexical), Boomerang reranker, hallucination detection |
| Document ingestion | Upload API, OCR, multi-format extraction | 100+ format ingestion, zero-config |
| Agent framework | Full ReAct loop with tools, hooks, MCP, streaming | Limited — vectara-agentic library (thin wrapper on LlamaIndex) |
| Multi-agent orchestration | 3 strategies via API | None |
| Workflow automation | DAG workflows with triggers | None |
| Database for app data | Postgres + PostgREST for custom tables | None — document corpus only |
| Auth for end users | GoTrue with RLS | API authentication only (not end-user auth) |
| Self-hosting | Yes (Docker or Kubernetes) | No — managed cloud only |
| Multi-language support | API-driven (any language) | 100+ languages for search out of the box |
Choose Vectara when RAG is your only need and you want zero-config document processing with strong hallucination detection. Powabase makes sense when you need RAG plus agents, orchestration, workflows, a database, and auth: a complete AI application backend.
## vs Dify
Dify is a popular open-source LLM application builder with a visual workflow canvas, built-in RAG, and multiple app types (chatbot, agent, workflow). Its visual interface makes it strong for prototyping and building AI apps.
| Capability | Powabase | Dify |
| --------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| RAG depth | 5 indexing strategies including tree-based (PageIndex) and structured extraction (Doc2JSON) | Standard chunking + embedding with configurable strategies |
| Agent framework | ReAct with hooks, approval flow, MCP, 8 builtin tools | ReAct agents with tool calling and conversation variables |
| Multi-agent | 3 orchestration strategies (Supervisor, Sequential, Parallel) | Workflow chaining of LLM/agent nodes (no native multi-agent collaboration) |
| Workflow engine | API-first DAGs with webhooks, cron/interval schedules | Visual canvas with branching, loops, error handling |
| Database for app data | Postgres + PostgREST (your own tables with RLS) | Internal Postgres only (for Dify state, not user data) |
| Auth for end users | GoTrue with email/OAuth/magic links | Admin auth only (no end-user auth, enterprise SSO is paid) |
| API-first design | Every feature accessible via REST API | Visual-first design — API is secondary |
| Per-project isolation | Fully isolated infrastructure per project | Shared infrastructure, workspace-level isolation |
Choose Dify when you want a visual builder for prototyping AI apps quickly, especially if your team prefers drag-and-drop over code. Powabase is the better choice when you need an API-first backend that your application code calls directly, with extensive RAG capabilities, per-project isolation, and included database/auth infrastructure.
## vs n8n
n8n is a general-purpose workflow automation platform with 400+ integration nodes. Its AI capabilities (AI Agent node, LLM nodes, memory nodes) are add-ons to a workflow engine, not AI primitives built for the job.
| Capability | Powabase | n8n |
| ----------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| Primary focus | AI application backend (RAG, agents, orchestration) | General-purpose workflow automation with AI add-ons |
| RAG pipeline | Managed end-to-end: ingest, index (5 strategies), retrieve (4 methods), rerank | None built-in — connect to external vector DBs and embedding APIs via nodes |
| Agent depth | ReAct with tools, hooks, approval, session memory, streaming | AI Agent node with tool calling and memory (no approval flow, limited streaming) |
| Integrations | 8 builtin tools, custom HTTP tools, MCP servers | 400+ pre-built integration nodes (Slack, Salesforce, databases, etc.) |
| AI streaming | SSE with full event lifecycle (tool calls, approval, steps) | Not designed for streaming AI responses |
| Workflow triggers | API, webhook, cron/interval schedules | Manual, webhook, cron, and app-specific triggers |
Choose n8n when you need to automate business processes across many SaaS tools and want to add some AI capabilities. Powabase fits when AI is your application's core function and you need extensive RAG, agent, and orchestration capabilities.
## vs CrewAI
CrewAI is a Python framework focused on multi-agent orchestration with role-based teams. It excels at modeling agent collaboration patterns: hierarchical delegation, sequential pipelines, and consensual decision-making.
| Capability | Powabase | CrewAI |
| ------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------- |
| Deployment model | Managed API | Framework — deploy in your infrastructure |
| Multi-agent | 3 strategies via API (Supervisor, Sequential, Parallel) | 3 process types (sequential, hierarchical, consensual) — code-defined |
| RAG | 5 indexing strategies, 4 retrieval methods, managed pipeline | Basic RAG with ChromaDB (no hybrid search, no reranking) |
| Database & Auth | Postgres + GoTrue included | None included |
| Workflow automation | DAG workflows with visual builder and AI Copilot | CrewAI Flows (code-defined, no visual builder) |
| Human-in-the-loop | Approval hooks with SSE events and approve endpoint | None built-in |
| Language support | REST API — any language | Python only |
Choose CrewAI when you want fine-grained Python control over multi-agent collaboration patterns and are comfortable managing your own infrastructure. Powabase is the better fit when you want managed multi-agent orchestration with a complete backend (database, auth, RAG) accessible from any language.
## What Makes Powabase Different
| Differentiator | What It Means |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Unified API | RAG, agents, orchestration, workflows, database, auth, and storage — all from one REST API with one auth model |
| Deep RAG (not just vector search) | 5 indexing strategies (including LLM-powered tree indexing and structured JSON extraction), 4 retrieval methods, cross-encoder reranking |
| Per-project isolation | Each project gets its own Postgres, API gateway, auth service, storage, and AI worker — no shared state between projects |
| API-first, language-agnostic | Every feature works via REST. Build in Python, TypeScript, Go, Rust, or any language that speaks HTTP |
| Human-in-the-loop as a primitive | Approval hooks pause agent execution via SSE and wait for a decision via API — built into the agent runtime, not bolted on |
| Three orchestration strategies | Supervisor (autonomous delegation), Sequential (pipeline), Parallel (fan-out + merge) — choose the right pattern for your use case |
| AI Copilot for workflows | Describe what you want in natural language and the copilot generates the workflow graph |
| Managed infrastructure | No vector DB to provision, no agent server to deploy, no auth system to build — it's all included and running |
## Next Steps
Understand the three core modules and how they work together.
Build an end-to-end RAG agent in 5 minutes.
Per-project isolation, database schemas, and request routing.
# Platform Overview
Source: https://docs.powabase.ai/concepts/platform-overview
Powabase is a fully managed backend (database, auth, storage, and AI) that your coding agent can build on top of. Point Claude Code, Cursor, Copilot, or an AI app builder like Replit, Base44, or Lovable at your project, and ship a real app without standing up any infrastructure.
## What is Powabase?
Powabase is the backend for your app: one managed service that gives you a Postgres database, user authentication, file storage, realtime, and a suite of AI features behind a single API. You don't deploy or operate anything. You create a project, copy two values, and start building.
The thing that makes Powabase a good fit for how people build today is that you don't have to write the backend by hand. Your coding agent can. Powabase speaks the same conventions the popular tools already know, so an agent like Claude Code, Cursor, or GitHub Copilot, or an AI app builder like Replit, Base44, Lovable, or Bolt, can wire your frontend up to a production backend for you. Powabase is the backend your app talks to; your agent writes the code that talks to it.
If you can copy a URL and an API key, you have everything you need to get started. The rest is your agent's job.
## Build with the coding agent you already use
You don't need to be a backend engineer to build on Powabase. You need the tool you're already using.
Every Powabase project exposes its connection details in one place: the **Connect modal** in the Studio. It gives you a Project URL and an API key, plus ready-to-paste connection strings in nine languages. Those two values are the entire handshake between your app and your backend.
Open the Studio, click **Connect** in your project header, and copy the **Project URL** and an **API key**. That's the only setup step.
Tell your agent what you're building and paste in the connection details. For example: *"I'm using Powabase as my backend. Here's my Project URL and API key. Add sign-up and login, store user profiles, and let users upload files."* The agent reads these docs, understands the API, and writes the integration code.
Review what your agent produced, run it, and iterate. Because Powabase is fully managed, there's nothing to deploy or scale on the backend; the project is already live.
**Already using an AI app builder?** Tools like Replit, Base44, Lovable, and Bolt know how to talk to a Supabase-style backend. Powabase is built on those same building blocks and follows the same API conventions, so anywhere a tool expects that kind of backend, you can give it your Powabase Project URL and key instead, and the database, auth, and storage just work. See [Migrating from Supabase](/guides/migrating-from-supabase) for the specifics.
This works because every endpoint follows the same patterns: the same authentication, the same error format, the same streaming protocol everywhere. That consistency is what an AI assistant needs to generate code that runs the first time.
How to give Claude Code, Cursor, Copilot, and others the context to build on Powabase.
Open the Connect modal, find your URL and keys, and make your first request.
## One backend for the whole app
Most apps need the same handful of backend pieces. Powabase includes all of them in every project, so you're not stitching together five services and writing glue code to connect them:
| You need… | Powabase gives you… |
| ------------- | --------------------------------------------------------------------- |
| A database | Postgres with `pgvector`, plus instant REST access to your own tables |
| User accounts | A full auth system — email/password, OAuth providers, JWTs |
| File storage | Upload, serve, and secure files with signed URLs |
| Live updates | Realtime subscriptions for changes as they happen |
| AI features | Document search (RAG), agents, and automated workflows — built in |
The database, auth, and storage surfaces follow the same conventions as Supabase, so the patterns you (or your agent) already know carry straight over. The difference is that last row: AI is part of the backend, not something you bolt on later.
## The AI features that set Powabase apart
When your app needs to do more than store and serve data, those capabilities are already in the box: understanding documents, holding a conversation, automating a multi-step process. You can ignore them until you need them, and reach for only the ones a given feature requires.
Upload PDFs, Word docs, images, or spreadsheets and get search that understands them. Extraction, indexing, and retrieval are handled for you, with multiple strategies for different document types.
LLM-powered agents that reason, call tools (database, web search, your own endpoints, MCP servers), and stream answers in real time, with optional human approval for high-stakes actions.
A coordinator that routes work to specialized agents (a billing agent, a support agent, a sales agent) all behind one endpoint.
DAG-based pipelines for the steps you know in advance, with AI reasoning inside each step. Trigger them manually, by webhook, or on a schedule.
These compose. A knowledge base can back an agent; an agent can be a step in a workflow; a workflow can search a knowledge base or call your database. You use as much or as little as your app needs.
**Example: a support assistant in three pieces.** Upload your help docs into a knowledge base, attach it to an agent so it answers grounded in your content, and wrap that agent in a workflow that escalates to a human when confidence is low. Each piece is a few API calls, and your coding agent can write all of them.
## Per-project isolation
Every project gets its own isolated stack: a dedicated Postgres database with `pgvector`, an API gateway, an auth service, file storage, and an AI service worker. There's no shared state between projects, and isolation is enforced at the infrastructure level, so what you build is genuinely yours and ready for production from day one.
The control plane, data plane, and how each project's stack fits together.
Direct PostgREST and Postgres access to your project database.
## Getting started
The fastest path is to open the Connect modal, hand the details to your coding agent, and describe what you want to build. If you'd rather see the API directly, the Quickstart builds a working RAG agent end-to-end in about five minutes.
Find your Project URL and keys, and make your first authenticated request.
Build an end-to-end RAG agent in 5 minutes.
Give your agent the context to build complete integrations.
Where Powabase fits next to Supabase, LangChain, and other tools.
# Rate limits
Source: https://docs.powabase.ai/concepts/rate-limits
Where Powabase enforces request-rate limits today (workflow executions at 20/min) and where it deliberately doesn't (the rest of the surface).
Powabase's only quantitative rate limit on the AI surface today applies to workflow execution endpoints. The rest of the API (agent runs, knowledge search, source CRUD, and so on) has no Kong-level or in-process limiter; abuse protection there comes from credit-based billing (you can't run 10,000 agents per second because you'd exhaust your credits).
This page covers what is rate-limited, what isn't, and how to handle the 429 response when you hit the workflow limit. For credit-based pre-dispatch refusals, see [Billing model](/concepts/billing-model).
## What's rate-limited
| Endpoint | Limit | Per | Window | Response |
| ----------------------------------------- | ----------- | -------- | ---------- | -------- |
| `POST /api/workflows/{id}/execute` | 20 requests | per user | 60 seconds | `429` |
| `POST /api/workflows/{id}/execute/stream` | 20 requests | per user | 60 seconds | `429` |
The limit is **per user, per project-service replica** (so per pod, not cluster-wide). It's a sliding-window in-memory counter: no Redis, no distributed coordination. In Powabase's v1 deployment there's one project-service replica per project (per `CLAUDE.md` deployment constraint), so per-pod and per-project are the same thing today.
When you exceed the limit:
```json theme={null}
{
"error": "Rate limit exceeded. Max 20 executions per minute."
}
```
The HTTP status is `429`.
## Rate limit by user identity
The limiter keys on `g.user_id`, which is set by `@require_auth` from the JWT's `sub` claim. Two consequences worth knowing:
* **Each end user gets their own 20/min budget.** Two users in the same project hitting the same workflow each get 20 executions per minute, independently.
* **Unauthenticated callers share a single `"anonymous"` budget.** If you're calling `/execute` with the Service Role key (which doesn't carry a user `sub`), the limit applies to a shared `"anonymous"` bucket: 20/min total across all unauthenticated callers. This is rarely an issue in practice but worth knowing if you have many backend services calling workflows.
## What isn't rate-limited
The rest of the AI surface:
* **Agent runs** (`POST /api/agents/{id}/run`, `/run/stream`): no limit. Each run is metered by credits, not request rate.
* **Orchestration runs** (`POST /api/orchestrations/{id}/run`): no limit. Same credit-metered story.
* **Knowledge base search** (`POST /api/knowledge-bases/{id}/search`): no limit.
* **Source operations** (upload, reextract, cancel, delete): no limit.
* **All CRUD on agents, KBs, workflows, tools, etc.**: no limit.
* **All `/auth/v1/*`, `/rest/v1/*`, `/storage/v1/*`, `/realtime/v1/*` BaaS endpoints**: no Kong-level rate limit. GoTrue has its own per-endpoint rate limits documented in [Auth model](/concepts/auth-model).
## Why workflows specifically
Workflow `/execute` is rate-limited because a workflow can be triggered by an external system (a webhook from Stripe, GitHub, cron, etc.) that loses control over its retry behavior. Without a limit, a misconfigured upstream retry storm could exhaust an org's credits in seconds. The 20/min cap is wide enough for normal usage and narrow enough to bound damage from a runaway loop.
Agent runs aren't limited the same way because they're typically initiated from your own application code with rate-limiting already in place client-side. You're not going to accidentally `POST /api/agents/{id}/run/stream` 10,000 times per second from a React app.
## What to do client-side
When you get a `429`:
* **Don't retry immediately.** The limiter's sliding window means a retry within the current minute will hit the same 429.
* **Back off exponentially with jitter.** Start at 3-5 seconds; double up to 30 seconds; add ±25% random jitter.
* **Surface the wait to the user.** "Trying again in 20 seconds" is better UX than spinning indefinitely.
A reasonable retry sequence: 3s → 6s → 12s → 30s → 30s → 30s, with jitter. After three full 30-second waits, give up and surface a hard error: you're either coding wrong or hitting a real abuse pattern.
## What to do server-side (your own backend)
If your application triggers workflows on behalf of end users (your service calling `/execute` with the Service Role key for many users), the shared `"anonymous"` budget will bite you at scale. Two options:
1. **Run each workflow execution under the end user's JWT.** Get the user's access token from your auth layer, pass it as `Authorization: Bearer ` instead of the Service Role key. Each user gets their own 20/min bucket. (You'll need to make sure RLS on `ai.workflows` lets the user execute; see [RLS Cookbook](/guides/rls-policies).)
2. **Throttle and queue client-side.** If using the Service Role for a fan-out pattern is the right shape, add a token-bucket limiter in front of your `/execute` calls: release 20 every 60 seconds, queue the overflow. Don't rely on Powabase's 429 to do throttling for you; that just trades steady throughput for retry overhead.
## Future quantitative limits
A few things to be explicit about today's posture so you can plan for changes:
* **There is no Kong-level rate limit** on any endpoint as of v1. The audit's earlier inspection of `kong_config.py` confirmed only `cors` and `key-auth` plugins are attached, with no `rate-limiting` plugin. A comment in that file calls webhooks "rate-limited" but it's aspirational; nothing actually enforces it at the gateway.
* **Per-IP limits are not enforced** by the platform. If your concern is unauthenticated abuse against the Anon Key-fronted endpoints (PostgREST, Storage public URLs), gate them behind your own CDN or WAF.
* **The 20/min workflows limit may move** as the platform tunes for usage patterns. The number isn't a hard product commitment.
## Next steps
The credit system: the other gate on AI surface usage besides rate limits.
The endpoints this limit applies to.
GoTrue's per-endpoint rate limits on the auth surface (separate from the AI surface limits documented here).
The webhook trigger surface: the most common abuse vector, and why workflows are limited.
# Realtime model
Source: https://docs.powabase.ai/concepts/realtime
Three channel types: Broadcast for ephemeral messages, Presence for online-state sync, Postgres Changes for table change streams. How auth, RLS, and the per-project tenancy fit together.
Powabase Realtime is Supabase Realtime v2.65.3 mounted at `/realtime/v1/` (WebSocket) and `/realtime/v1/api` (REST) on your project URL. It's a long-lived bidirectional channel service that lets clients subscribe to events from three sources: messages broadcast by other clients, presence state of who's online, and row-level changes from your Postgres database.
This page covers the conceptual model: the three channel types, how they compose with auth and RLS, and the Powabase-specific gotchas around tenancy and the publication setup. For the API surface and WebSocket protocol, see [Realtime Reference](/api-reference/realtime). For worked examples, see [Realtime subscriptions](/guides/realtime-subscriptions).
## The three channel types
Realtime exposes three independent subscription primitives, all multiplexed over a single WebSocket connection. A channel can use one, two, or all three at once.
**Broadcast.** Clients send messages to a named channel, and everyone subscribed to that channel receives them. No persistence; if a client isn't connected, they miss the message. Use this for cursor positions, live-cursor selections, typing indicators, anything where the latest state matters and history doesn't.
**Presence.** Each client publishes its own "I'm here" state to a channel, and every other client sees the aggregate. Internally it's tracked through `track`/`untrack` operations and reconciled across the cluster. Use this for "who's online right now," collaborator avatars on a shared document, etc.
**Postgres Changes.** Realtime subscribes to the project's logical replication slot and forwards row changes (INSERT, UPDATE, DELETE) matching the client's filter to the channel. Filters can target a schema, table, column equality, and event type. Use this for "live update the UI when this table changes" patterns without polling.
All three are addressed by **channel name**, an arbitrary string the client chooses (e.g., `room:42`, `cursors:doc-abc`, `db:public:orders`). Channels with the same name share state across all connected clients.
## Auth model
Realtime authenticates the WebSocket connection with the project's JWT, exactly like the rest of the BaaS surface. The client sends `apikey` and `Authorization` query parameters at WebSocket handshake time:
```
wss://{ref}.p.powabase.ai/realtime/v1/websocket?apikey=&vsn=1.0.0
```
If the token is the Anon Key, the connection's database role is `anon`. If it's a signed-in user's access token, the role is `authenticated`. Service Role tokens get `service_role`. This is the same role mapping you've already met in [RLS Model](/concepts/rls-model) and [Auth model](/concepts/auth-model).
After the connection is up, each channel subscription can be either **public** or **private**:
* **Public channels.** Anyone connected with a valid Anon or user token can subscribe. No RLS check.
* **Private channels.** Joining the channel requires a passing RLS policy on the `realtime.messages` table. The standard pattern is `CREATE POLICY ... ON realtime.messages FOR SELECT TO authenticated USING ()`. Without a passing policy, the join request returns an error and the channel never opens.
You opt into private mode when subscribing: the client signals `config: { private: true }` in the join payload. Public is the default.
For Postgres Changes specifically, the filtering is **server-side after the policy check**. RLS on the underlying table (e.g., `public.orders`) also applies, so Realtime won't forward a row change unless the client has a passing SELECT policy for that row. A private channel with a postgres\_changes config gives you per-user filtering essentially for free, as long as your RLS posture on the table is right.
## The Powabase-specific gotchas
Two things Powabase does differently from a vanilla Supabase deployment, both of which surface as opaque failures if you don't know about them.
### Kong preserves the Host header
Realtime is multi-tenant at the image level (one Realtime process can serve multiple projects, identified by the hostname). On Powabase, every project gets its own Realtime pod, but the image still parses tenant\_id from the Host header. If Kong rewrites Host to the upstream service name (`realtime.svc.cluster.local`), Realtime looks up tenant "realtime", doesn't find it, and returns **`403 TenantNotFound`** on every WS upgrade.
Powabase's Kong config sets `preserve_host: True` on both the WS and REST routes to prevent this. It's handled at the platform layer, so you don't need to do anything. It's worth knowing about for one reason: if you ever see `403 TenantNotFound` from a self-hosted Realtime deployment, this is what's wrong.
### Logical replication slot setup
Postgres Changes works by tailing the project's WAL through logical replication. By default, Powabase projects ship **without** a `supabase_realtime` publication configured. Your postgres\_changes subscriptions will succeed, but you won't receive any events until you create the publication and add tables to it:
```sql theme={null}
-- Enable Realtime for specific tables
CREATE PUBLICATION supabase_realtime FOR TABLE
public.orders,
public.messages,
public.cursors;
-- Or all tables in a schema
CREATE PUBLICATION supabase_realtime FOR ALL TABLES IN SCHEMA public;
```
After this, INSERT/UPDATE/DELETE on those tables are streamed to Realtime, which forwards them to subscribed clients. To stop replicating a table:
```sql theme={null}
ALTER PUBLICATION supabase_realtime DROP TABLE public.orders;
```
If you don't see postgres\_changes events arriving, this is the first thing to check. The publication is project-wide; you only need to set it up once.
## realtime.send() and realtime.broadcast\_changes()
The Realtime image installs two SQL functions in the `realtime` schema that let your Postgres code emit messages to channels server-side. Useful for "broadcast a notification when this row changes" patterns where the trigger logic lives in the database, not the application.
**`realtime.send(payload jsonb, event text, topic text, private bool default false)`** sends a custom message to the given topic (channel name). All clients subscribed to that channel receive it as a broadcast event. The `private` flag controls whether the receiving channel needs to be subscribed as private.
**`realtime.broadcast_changes(topic text, event text, op text, table text, schema text, new record, old record, level text)`** is a wrapper for "broadcast this row change as a structured event," typically called from a trigger function. The `level` parameter is the channel-private level (it defaults to private, so you need RLS on `realtime.messages` for the trigger to actually deliver).
A worked trigger example:
```sql theme={null}
CREATE OR REPLACE FUNCTION broadcast_order_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
topic => 'orders:' || COALESCE(NEW.user_id::text, OLD.user_id::text),
event => TG_OP,
op => TG_OP,
table => 'orders',
schema => 'public',
new => NEW,
old => OLD,
level => 'topic'
);
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER orders_realtime
AFTER INSERT OR UPDATE OR DELETE ON public.orders
FOR EACH ROW EXECUTE FUNCTION broadcast_order_change();
```
Each user subscribes to their own `orders:` channel and gets only their own changes. This is more efficient than postgres\_changes when you have many users and don't want every client filtering through every row change.
## When to use which
A rough decision tree for picking the right channel type:
| Use case | Channel type |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Live cursor positions, drag previews, typing indicators | Broadcast |
| "Who's currently viewing this page" sidebar | Presence |
| Live-updating list when database changes | Postgres Changes |
| Server-pushed notifications based on business logic | `realtime.send()` from a trigger |
| Chat / messaging where history matters | **Don't use Realtime.** Write to a table and use Postgres Changes + REST history fetch |
Broadcast and Presence are ephemeral; Postgres Changes is durable (the underlying rows persist). The "use case" question is essentially "do I need history?" If yes, write to the database and subscribe; if no, broadcast.
## What Realtime is *not* good for
A few things to be explicit about:
* **Pub/sub for backend services.** Realtime's WebSocket is designed for thousands of browser clients, not hundreds of backend services holding persistent connections. For service-to-service eventing, use Postgres `LISTEN`/`NOTIFY` (note: not over the PgBouncer pooler, see [Connection pooling](/guides/connection-pooling)) or a dedicated message broker.
* **Reliable delivery.** Realtime is best-effort. A dropped WebSocket means the messages sent during that window are gone. If you need at-least-once delivery, the broadcast pattern is the wrong fit: write to a durable queue and have subscribers tail it.
* **Strict ordering across channels.** Within a single channel, ordering is preserved. Across channels (or across multiple connections of the same client), ordering is not guaranteed.
* **High-frequency message rates.** Realtime can handle hundreds of messages per second on a channel. If you're broadcasting raw mouse coordinates at 60Hz, throttle client-side or you'll overrun the buffer.
## Next steps
Three worked patterns: live-updating list, presence cursors, broadcast chat.
WebSocket protocol, channel config shape, REST broadcast endpoint, error codes.
How the auth layer that gates private channels works.
The JWT-and-role model Realtime shares with the rest of the BaaS surface.
# Row Level Security
Source: https://docs.powabase.ai/concepts/rls-model
How Powabase translates an HTTP request's API key or JWT into a Postgres role, and how RLS policies decide which rows that role can see.
Row Level Security (RLS) is the mechanism that lets you safely expose your project's database to clients you don't fully trust: browsers, mobile apps, partner integrations. Every request goes through PostgREST (or direct Postgres), which sets a database role on the session based on the credential you sent, then runs your SQL with that role applied. Policies you define on each table decide which rows that role is allowed to see, insert, update, or delete.
This page explains the request-to-role mapping, the auth helper functions (`auth.uid()`, `auth.jwt()`, `auth.role()`), and the trade-offs between the four credentials Powabase issues per project. For policy patterns, see the [RLS Cookbook](/guides/rls-policies). For local testing without spinning up a frontend, see [RLS Testing](/guides/rls-testing).
## The four credentials and their roles
The Connect modal hands out five values; four of them are credentials, and each maps to a distinct Postgres role:
| Credential | Postgres role | Pre-issued JWT? | Typical use |
| ------------------------------ | ----------------------- | --------------------------------------------- | --------------------------------------------------------------------------- |
| Anon (Publishable) Key | `anon` | Yes (signed `aud=authenticated`, `role=anon`) | Embedded in clients; sets the floor for "what unauthenticated visitors see" |
| Signed-in user access token | `authenticated` | Issued by GoTrue on sign-in | What your app gets back after `POST /auth/v1/token` |
| Service Role (Secret) Key | `service_role` | Yes (signed `role=service_role`) | Server-side; bypasses RLS |
| Database URL (direct Postgres) | `[` (project owner) | n/a | Migrations, admin scripts; not RLS-checked |
The Anon Key and Service Role Key are **pre-issued long-lived JWTs** that the platform mints when it provisions your project. They're shaped exactly like a user access token, but their `role` claim is hard-coded to `anon` and `service_role` respectively. PostgREST reads the `role` claim and sets the session's database role to match: `SET LOCAL ROLE anon` or `SET LOCAL ROLE service_role`.
A signed-in user's token has `role=authenticated`. PostgREST sets the role to `authenticated`, and your RLS policies that target `TO authenticated` apply.
## The auth helper functions
Once PostgREST has set the role and stored the JWT claims as a session-local setting (`request.jwt.claims`), three SQL functions in the `auth` schema give policies access to the user's identity:
```sql theme={null}
auth.uid() -- uuid, the signed-in user's id (from the 'sub' claim)
auth.role() -- text, the 'role' claim ('authenticated', 'anon', 'service_role')
auth.jwt() -- jsonb, the full decoded JWT payload
```
You use these in `USING` and `WITH CHECK` policy expressions:
```sql theme={null}
-- Only let users read their own profile
CREATE POLICY own_profile ON public.profiles
FOR SELECT TO authenticated
USING (id = auth.uid());
-- Only let users update their own profile
CREATE POLICY update_own_profile ON public.profiles
FOR UPDATE TO authenticated
USING (id = auth.uid()) WITH CHECK (id = auth.uid());
```
`auth.uid()` returns `NULL` for `anon` requests (they have no `sub`). That means an `anon`-targeting policy can check `WHERE owner = auth.uid()` and it'll just never match. Safe by default.
Custom claims you set in JWTs (via GoTrue hooks or your own minting) land in `auth.jwt()`:
```sql theme={null}
-- Read a custom claim
CREATE POLICY only_admins ON public.audit_log
FOR SELECT TO authenticated
USING (auth.jwt() ->> 'is_admin' = 'true');
```
## The defaults
When a new project is provisioned, `public` ships empty (you bring your own tables) and `ai` ships with a full default policy set.
**`public` schema, empty by default.** RLS is enabled on no tables until you create some. When you create a table in `public`, RLS is **off** by default. You must `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` and add policies, otherwise PostgREST will refuse the request (it requires either RLS-enabled with policies, or grants, see the [Cookbook](/guides/rls-policies)).
**`ai` schema, RLS enabled on every table.** Default policies are:
* **`service_role`** has full access (`FOR ALL USING (true) WITH CHECK (true)`) on every table. This is what the platform's own backend uses.
* **`authenticated`** has read access on every table. Most config tables (`agents`, `workflows`, `tools`, `knowledge_bases`, `sources`) are also writable. Session-shaped tables (`agent_sessions`, `agent_runs`, `orchestration_sessions`, `orchestration_runs`) read with per-user filtering via `auth.uid() = user_id OR user_id IS NULL`.
* **`anon`** has no policies. Anon-key requests against `ai.*` return empty.
**The default `authenticated` policies on `ai.*` are project-wide, not per-user.**
If two users sign into the same project, they can see each other's agents, workflows, knowledge bases, and configurations. Only the session-shaped tables filter by `user_id`. For multi-tenant scenarios where you don't want this, see the tenant-isolation pattern in the [RLS Cookbook](/guides/rls-policies).
## Client-side vs server-side: when each role is right
Service Role and Anon aren't "tiers of privilege." They're for different settings entirely.
**Use the Anon Key client-side.** Embed it in browser JS, mobile apps, anything that ships to user devices. RLS is what makes this safe: even though the key is public, the policies you write decide what `anon` (and `authenticated`, after sign-in) can do. The key's value is its `role` claim. Possessing it doesn't grant access; the policies do.
**Use the Service Role Key server-side only.** It bypasses RLS entirely (`service_role` has `BYPASSRLS` set on the database role). Treat it like a database password: never ship it in client code, never embed it in a `Authorization` header you trust to a third party. It's for your backend server-to-server calls, batch jobs, migrations, the typed `/api/*` surface, and anything else inside your trust boundary.
**Use signed-in user tokens for end-user identity.** When a user signs in via `POST /auth/v1/token?grant_type=password`, GoTrue returns an `access_token` and `refresh_token`. Send the access token as `Authorization: Bearer ` on every subsequent request. PostgREST will set the role to `authenticated` and your `auth.uid()` lookups will return that user's id.
**Use the Database URL only from trusted, server-side environments.** It's a `][` Postgres user with full schema ownership, and RLS doesn't even apply (the role has `BYPASSRLS`). Use it for migrations, BI tools, and admin scripts; never for application traffic.
## Composition with the typed `/api/*` surface
The typed AI endpoints (`/api/agents`, `/api/sessions`, etc.) authenticate with the Service Role key and do their own ownership checks at the application layer. That means the RLS policies on `ai.*` don't directly affect those endpoints; the backend is already running with full access.
What RLS does affect is **what your end users see when they query `ai.*` directly via PostgREST.** If you're not exposing `ai.*` to end users (you only hit `/api/*` from your backend), the defaults are fine; nobody on a user JWT ever touches it. If you are exposing `ai.*` reads to end users (custom dashboards, real-time subscriptions), watch out for the defaults' "any authenticated user sees everything" posture.
## Next steps
Five patterns: own-rows-only, public-read+auth-write, tenant isolation, role-based, soft-delete.
Test policies in psql or the SQL Editor without a frontend.
Where the four credentials come from in the Studio.
The companion page for how RLS interacts with the AI-surface tables.
# Schemas
Source: https://docs.powabase.ai/concepts/schemas
The five schemas you'll find in a Powabase project's Postgres: public (yours), ai (platform-managed), auth (GoTrue), storage (Storage API), and extensions (where pg_net and friends live).
A Powabase project's Postgres database has five user-visible schemas, plus a handful of internal ones (`_realtime`, `supabase_functions`, etc.) you'll occasionally see in `pg_namespace`. This page explains what each schema contains, who owns it, and which ones are safe to touch.
For the AI-schema-specific queryability story, see [Querying the ai schema](/concepts/ai-schema-postgrest). For RLS, see [RLS Model](/concepts/rls-model). For migration boundaries, see [Migrations](/guides/migrations).
## The five user-visible schemas
| Schema | Owner | Purpose | Safe to migrate? |
| ------------ | ----------- | ----------------------------------------------- | ---------------- |
| `public` | You | Your application tables | Yes |
| `extensions` | You | User-installed Postgres extensions | Yes |
| `ai` | Platform | Sources, KBs, agents, runs, sessions, workflows | No |
| `auth` | GoTrue | User accounts, sessions, OAuth identities | No |
| `storage` | Storage API | Bucket and object metadata | No |
### `public`: your tables
Empty by default. You add your application tables here: users (or `profiles` joining to `auth.users`), posts, orders, etc. RLS is **off** for new tables in `public` unless you explicitly enable it (`ALTER TABLE my_table ENABLE ROW LEVEL SECURITY`).
PostgREST exposes `public` at `/rest/v1/*` without needing the `Accept-Profile` header; it's the default schema for the API.
### `ai`: the AI surface's state
Where the typed `/api/*` endpoints store their data. 35+ tables for sources, knowledge\_bases, indexed\_sources, chunks, embeddings, agents, agent\_sessions, agent\_runs, workflows, workflow\_executions, etc. Full inventory in [Querying the ai schema](/concepts/ai-schema-postgrest#tables-in-the-ai-schema).
**Don't migrate `ai.*`.** The platform's services assume the schema's invariants, and modifying them risks data corruption or service-side breakage. Read freely (RLS lets `authenticated` SELECT most tables); never schema-change.
PostgREST exposes `ai` at `/rest/v1/*` with `Accept-Profile: ai` for reads and `Content-Profile: ai` for writes. RLS gates which rows each role can touch.
### `auth`: GoTrue's state
User accounts (`auth.users`), refresh tokens (`auth.refresh_tokens`), OAuth identities (`auth.identities`), MFA factors (`auth.mfa_factors`), email-change records, recovery tokens. GoTrue's own migrations manage all of it; the project init SQL just creates the schema.
**Don't migrate `auth.*`.** GoTrue runs its own schema migrations on startup; conflicting changes break the auth surface.
PostgREST does **not** expose `auth`; there's no Accept-Profile that gets you in. Use the typed `/auth/v1/*` API instead.
### `storage`: Storage API's state
`storage.buckets` (one row per bucket), `storage.objects` (one row per uploaded file), plus a handful of helper tables and functions (`storage.foldername()`, `storage.filename()`). Managed by the Storage API.
PostgREST exposes `storage` at `/rest/v1/*` (it's in the schemas list). You can read `storage.objects` to list files via SQL, and RLS policies on `storage.objects` are how you gate object access in private buckets. Don't write to `storage.objects` directly. Go through `/storage/v1/object/...`, which keeps the underlying S3 backend in sync.
See [Storage policies](/guides/storage-policies) for the policy patterns.
### `extensions`: where Postgres extensions land
When you `CREATE EXTENSION foo`, the extension's objects live in whatever schema the extension specifies, which by Powabase convention is `extensions`:
```sql theme={null}
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
```
You can install extensions yourself (from the allowlist, see [Extensions](/api-reference/extensions)) into `extensions`. The platform pre-installs `pg_net` and `vector` here at project provision time.
## Internal schemas you'll see
These exist for platform internals and aren't intended for user code, but you'll notice them in `pg_namespace` listings:
| Schema | Purpose |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `_realtime` | Realtime's per-tenant configuration and replication state |
| `supabase_functions` | The `http_request()` trigger function plus the audit table for triggered hooks (used by [DB webhooks](/guides/db-webhooks)) |
| `realtime` | The `realtime.send()` and `realtime.broadcast_changes()` SQL functions |
| `graphql_public` | pg\_graphql's mount point (queryable via `POST /rest/v1/rpc/graphql`) |
| `vault` | Vault extension's encrypted secrets storage (preloaded but no platform code uses it) |
| `pgsodium`, `pg_catalog`, `information_schema`, etc. | Standard Postgres internals |
You can call functions in `realtime` and `supabase_functions` from your own code, which is why they're documented above. The rest are internal.
## search\_path and what schemas resolve
Your Postgres session's `search_path` controls which schemas are searched for unqualified table references. The default is `"$user", public, extensions`, so `SELECT * FROM users` looks in `public` first, then `extensions`.
To query `ai.*` from `psql` without qualifying every table:
```sql theme={null}
SET search_path TO ai, public, extensions;
SELECT * FROM agents; -- Resolves to ai.agents
```
PostgREST sets the search\_path automatically based on the `Accept-Profile` / `Content-Profile` headers, so you only think about it for direct SQL connections.
## What schema your role can see
Each Postgres role has different `USAGE` grants on each schema. The defaults that matter:
| Role | `public` | `ai` | `auth` | `storage` |
| ------------------------------------------- | -------- | ---------------------- | ------ | --------- |
| `anon` | USAGE | USAGE (RLS denies all) | — | USAGE |
| `authenticated` | USAGE | USAGE (RLS-gated) | — | USAGE |
| `service_role` | USAGE | USAGE (RLS bypassed) | — | USAGE |
| `supabase_admin` (you in psql / migrations) | USAGE | USAGE | USAGE | USAGE |
Schema USAGE is just "can the role know this schema exists"; RLS policies on individual tables decide what's readable. The `auth.*` schema isn't granted to anon / authenticated / service\_role. They can't even see it in `\dn` output, let alone query `auth.users`.
## Next steps
What's in ai.\* and how to query it under RLS.
Which extensions are preloaded vs CREATE EXTENSION-able.
The role mapping that drives which schemas / rows each request can see.
The schema boundary you should not cross when migrating.
# Sources & Extraction
Source: https://docs.powabase.ai/concepts/sources-extraction
Sources are the entry point for documents in the platform. Upload files, and the extraction pipeline converts them into structured text that can be indexed into knowledge bases.
## What is a Source?
A Source represents a single uploaded document. When you upload a file, the platform creates a Source record, stores the original file, and kicks off an asynchronous extraction pipeline. The pipeline extracts text content from the document, handling PDFs, Word documents, images (via OCR), and other file types. The result is clean, structured text organized by page.
## Supported File Types
| Type | Extensions | Extraction Method |
| ---------- | ------------------------------------- | ------------------------------------------------------------- |
| PDF | .pdf | Multiple extractors — see 'Extraction Models' below |
| Word | .docx, .doc | Structured text extraction preserving headings and formatting |
| Images | .png, .jpg, .jpeg, .webp, .gif, .tiff | OCR (Optical Character Recognition) |
| Text | .txt, .md, .csv | Direct text reading |
| PowerPoint | .pptx | Slide-by-slide text extraction (REST API only) |
| Excel | .xlsx | Sheet-by-sheet cell content extraction (REST API only) |
| URLs | http(s):// | Fetched via URL import — single URLs, crawl, or sitemap |
## Extraction Models (PDF)
For PDFs you can choose how the text is extracted by passing extraction\_model at upload time or via POST /api/sources/\{id}/reextract. If you don't pass one, the pipeline uses auto, which tries mistral → opendataloader → fitz → pdfplumber in order until one succeeds. paddleocr and lighton are not part of the auto chain — request them explicitly.
| Model | What it does | Requires |
| -------------- | --------------------------------------------------------------------- | ----------------------------------------- |
| auto | Default fallback chain (mistral → opendataloader → fitz → pdfplumber) | — |
| mistral | Mistral OCR — scanned PDFs, image-heavy documents | MISTRAL\_API\_KEY |
| paddleocr | PaddleOCR-VL API — strong non-English support and layout detection | PADDLEOCR\_API\_KEY, PADDLEOCR\_BASE\_URL |
| lighton | LightOn OCR API | LIGHTON\_API\_KEY, LIGHTON\_BASE\_URL |
| opendataloader | Local high-accuracy structural extraction (tables, headings, layout) | None (local) |
| fitz | PyMuPDF — fast, text-based extraction | None (local) |
| pdfplumber | Reliable fallback for complex tables and unusual layouts | None (local) |
## Extraction Pipeline
When you upload a file, it goes through several stages: the file is stored in project storage, a Celery worker picks up the extraction task, the appropriate strategy is selected based on file type, and the extracted content is stored as derivatives (page texts, markdown, per-page images) associated with the source. This runs asynchronously, so poll the source status to check progress.
]
## Status Lifecycle
Every source goes through an extraction\_status lifecycle. After upload the source is pending; a worker picks it up and it moves to extracting; on success it becomes extracted. If some pages fail but others succeed the status is attention\_required (a partial success that's still indexable). Terminal states are extracted, failed, attention\_required, and cancelled.
| Status | Meaning |
| ------------------- | ------------------------------------------------------------------------------ |
| pending | Uploaded but not yet picked up by a worker |
| extracting | Currently being extracted |
| extracted | Extraction finished — derivatives available, source is indexable |
| attention\_required | Partial success — some pages failed. error\_message explains. Still indexable. |
| failed | Extraction failed — check error\_message for details |
| cancelled | User cancelled via POST /api/sources//cancel |
## Storage Integration
You can also import files that are already in your project's storage buckets using the import-from-storage endpoint. This avoids re-uploading files and is useful when you have an existing storage workflow. The extraction process is the same regardless of whether the file was uploaded directly or imported from storage.
## Next Steps
Step-by-step guide to uploading and extracting.
Turn extracted text into searchable vectors.
Full endpoint documentation.
# Storage model
Source: https://docs.powabase.ai/concepts/storage-model
How Powabase Storage organizes files into buckets, who can access what, what the size and MIME limits actually are, and how image transformations happen.
Powabase Storage is the Supabase Storage API (v1.33.0) running at `/storage/v1/*` on your project URL. It manages files in **buckets** with metadata mirrored into the `storage.buckets` and `storage.objects` tables in your project's Postgres so you can apply Row Level Security to file access just like any other table. On managed cloud the backend is S3; on a self-hosted Docker deployment the backend is local-disk by default (set `STORAGE_BACKEND=s3` plus the matching credentials to use S3 instead).
This page covers the conceptual model: buckets vs objects, public vs private, how the per-project S3 prefix works, and the size/MIME constraints. For the API surface, see [Storage Reference](/api-reference/storage). For uploads, see [Storage uploads](/guides/storage-uploads). For RLS on `storage.objects`, see [Storage policies](/guides/storage-policies).
## Buckets and objects
Every file lives inside a **bucket**, a top-level container with its own name, public/private flag, and optional MIME allowlist. Within a bucket, files are addressed by an arbitrary slash-separated **path** (Storage doesn't model "folders"; they're just substrings of the path).
```
/
↓ examples:
avatars/user-abc.png
documents/2026/q1/invoice-101.pdf
videos/marketing/landing.mp4
```
Two tables back this:
* **`storage.buckets`**: one row per bucket: name, public flag, allowed MIME types, file size limit, owner. You manage buckets via `POST /storage/v1/bucket` or directly via PostgREST on `storage.buckets`.
* **`storage.objects`**: one row per uploaded file: bucket\_id, name (path), size, mimetype, metadata, owner. Storage API populates this on every upload; you read it via PostgREST to list files or apply RLS.
Because object metadata lives in Postgres, you can join across `storage.objects` and your own `public.*` tables. A common pattern: a `public.documents` table with a `storage_path` column, joined to `storage.objects` for size/last-modified.
## Public vs private buckets
Buckets have a `public` boolean that gates one specific URL shape:
* **`GET /storage/v1/object/public/{bucket}/{path}`**: no auth required, returns the file directly. Only works for buckets where `public = true`.
* **`GET /storage/v1/object/authenticated/{bucket}/{path}`**: requires `Authorization: Bearer `. RLS on `storage.objects` decides whether the request succeeds.
* **`GET /storage/v1/object/sign/{bucket}/{path}`**: returns a pre-signed URL valid for a configurable TTL. Useful for sharing private files via email/links without making the whole bucket public.
For RLS on the private side, `storage.objects` is a regular Postgres table with policies you can apply just like your own tables. See [Storage policies](/guides/storage-policies) for patterns.
**Public buckets are fully public.** A `public = true` bucket means anyone with the URL (no API key, no JWT, no referer check) can fetch any file in it. There's no per-object override. Don't store anything sensitive in a public bucket, ever.
## The per-project S3 prefix
All Powabase projects share a single S3 bucket on the backend, but each project's files are namespaced under `s3:///projects/[/`. The Storage API enforces this prefix at the application layer, so your project's reads and writes are constrained to its own subtree, even though the underlying S3 bucket is shared.
You don't need to think about this in normal API use. It does matter if:
* **You want to give an external system direct S3 access.** You can't issue an AWS IAM role scoped to "this project's prefix" without coordinating with the platform team. For external integrations, use signed URLs (TTL-bounded) or stream through your own backend.
* **You're auditing storage usage.** Backups, S3 logs, and CloudWatch metrics live at the global-bucket level. To attribute usage to a project, filter by the `projects/{ref}/` prefix.
## File size limit
The default file size limit is **50 MB** (`52428800` bytes), enforced by the Storage API before forwarding to S3. Larger uploads return `413 Payload Too Large`.
For files between 50 MB and several GB, use **resumable uploads** (TUS protocol at `/storage/v1/upload/resumable`). TUS chunks the upload and persists progress, so a dropped connection mid-upload can be resumed rather than restarted. The per-chunk size, not the total file size, is what hits the 50 MB limit, so set chunk size to under 50 MB and you can upload arbitrarily large files. See [Storage uploads](/guides/storage-uploads) for the TUS client setup.
For files much larger than a few GB (video archives, datasets), TUS works but client-side handling gets heavyweight. Consider whether the file belongs in object storage at all. Versioned datasets often want dataset-specific tooling, not a generic file API.
## MIME allowlist
Each bucket can optionally restrict which content types are accepted. Set it at bucket creation:
```bash theme={null}
POST /storage/v1/bucket
{
"name": "avatars",
"public": true,
"allowed_mime_types": ["image/png", "image/jpeg", "image/webp"],
"file_size_limit": 5242880
}
```
When set, uploads with a non-matching `Content-Type` header return `400 invalid_mime_type`. When `allowed_mime_types` is null (the default), the bucket accepts anything within the global file size limit.
Bucket-level `file_size_limit` overrides the project default for files in that bucket. Useful when you want a low limit on user-uploaded avatars but a higher limit on internal-only document buckets.
**The MIME check is client-trust-based.** The Storage API checks the `Content-Type` header your client sends, not the actual file contents. A malicious client could upload an executable with `Content-Type: image/png` and bypass the allowlist. For untrusted clients, validate file contents server-side (e.g., by running magic-byte detection) before treating the upload as the claimed type. Treat the MIME allowlist as a UX safeguard, not a security boundary.
## Image transformations
Powabase ships an **imgproxy** service in the shared infrastructure, fronted by the Storage API. When you fetch an image, you can pass transformation parameters as a query string and the response is the transformed image:
```
GET /storage/v1/render/image/public/{bucket}/{path}?width=200&height=200&resize=cover&quality=80
```
Supported transforms include `width`, `height`, `resize` (cover/contain/fill), `quality`, `format` (webp, png, jpeg), and a few others. The transformed images are cached by imgproxy, so the second request for the same parameters is fast.
This is the right tool for serving multiple sizes of user-uploaded avatars, generating thumbnails for a media gallery, or converting between formats on the fly. It's not the right tool for batch processing; for that, do the transformation server-side and upload the results as separate objects.
The render endpoint mirrors the object endpoint shape: `/render/image/public/`, `/render/image/authenticated/`, and `/render/image/sign/` for public, RLS-gated, and signed-URL access respectively.
## Owner column and `auth.uid()`
`storage.objects` includes an `owner` column (uuid) set at upload time. By default it's the `sub` claim from the uploading user's JWT, i.e. `auth.uid()` at upload time. RLS policies can use this for "users can only access files they uploaded" patterns:
```sql theme={null}
CREATE POLICY own_objects ON storage.objects
FOR SELECT TO authenticated
USING (owner = auth.uid());
```
If the upload is unauthenticated (using the Anon Key with no user session), `owner` is null. Service-role uploads can explicitly set `owner` to any user uuid via the `x-owner` header.
## Storage and the AI schema
Powabase's Sources pipeline (`/api/sources/upload`) ingests files into Storage internally: the platform creates the bucket, manages the path, and tracks the lifecycle. **Those files are not the same as files you upload via `/storage/v1/*`.**
* **User Storage**: buckets you create, files your app uploads. Lives in `storage.objects` under buckets you own.
* **Platform Sources**: files uploaded via `/api/sources/upload`. Lives in a platform-managed bucket; the rows you see are in `ai.sources`, not `storage.objects`.
If your app needs both user-uploaded files (for display) and user-supplied documents for RAG (for indexing), keep them separate. Don't try to share buckets between the two flows; the AI surface assumes ownership of its bucket layout and will overwrite paths.
## Next steps
Browser-direct uploads, signed URLs, and TUS resumable uploads.
RLS patterns on storage.objects — own-files-only, public-read, role-based.
Full /storage/v1/\* endpoint catalog.
The role-and-policy model that gates storage.objects.
# Streaming & SSE
Source: https://docs.powabase.ai/concepts/streaming-patterns
The platform uses Server-Sent Events (SSE) for real-time streaming of agent responses, orchestration flows, and workflow executions. This page covers the event format, event types, and consumption patterns.
## SSE Overview
Server-Sent Events (SSE) provide a one-directional stream from server to client over a single HTTP connection. When you call a streaming endpoint (e.g. /api/agents/\{id}/run/stream), the response is a text/event-stream with individual events sent as they occur. Each event is a JSON object prefixed with 'data: ' on a single line, separated by blank lines.
## Agent Streaming Events
| Event | Key Fields | Description |
| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start` | `run_id`, `session_id` | Agent run has started — save `session_id` for multi-turn |
| `step_started` | `step` | Agent is beginning a new reasoning step |
| `content_delta` | `delta` | A per-token piece of the agent's text response. Accumulated server-side into the persisted run; the platform forwards each delta as it arrives. |
| `chunk` | `content` | Terminal text event with the final assembled response — emitted at the end of a non-ReAct (sync-style) generation |
| `tool_call` | `tool_name`, `arguments` | Agent is calling a tool with the given arguments |
| `tool_result` | `tool_name`, `result` | Tool execution completed with this result |
| `reasoning_delta` | `delta` | Per-token piece of the model's reasoning trace (only when `reasoning_requested=true`). Forwarded but NOT persisted — use `reasoning` for the persisted trace. |
| `reasoning` | `text` | Persisted reasoning segment — the canonical "what the model thought" event for replay |
| `reasoning_summary` | `summary` | Final summarised reasoning trace at the end of the step |
| `step_completed` | `step` | Agent finished a reasoning step |
| `approval_requested` | `tool_name`, `tool_input` | Tool call paused — waiting for approval via the approve endpoint |
| `context_handler_created` | `context_handler_id` | A context handler was created mid-run (e.g. by a knowledge\_search tool call). Lets you display the citation panel for the run. |
| `complete` | `run_id`, `content`, `usage` | Agent run finished |
| `error` | `message` | An error occurred during execution |
### Keepalive
If no event has been emitted for 30 seconds, the stream sends `: keepalive\n\n` (an SSE comment line). Most SSE clients ignore comment lines automatically. If you're rolling your own parser, drop any line that starts with `:`.
### Persisted vs. forwarded-only
The platform distinguishes events that get written to the run record from events that are forwarded for UI ergonomics but not persisted:
* **Forwarded only**: `content_delta`, `reasoning_delta`. These are per-token deltas; the assembled content / reasoning is persisted as one piece on `complete`.
* **Persisted + forwarded**: everything else.
If you replay a run via `GET /api/agents/runs/{run_id}`, you'll see the assembled content but not the individual deltas.
## Consuming SSE in Python
```python Python theme={null}
import requests
import json
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json={"message": "What can you help me with?"},
stream=True,
)
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if text.startswith("data: "):
event = json.loads(text[6:])
event_type = event["event"]
if event_type == "start":
print(f"Session: {event['session_id']}")
elif event_type == "chunk":
print(event["content"], end="", flush=True)
elif event_type == "tool_call":
print(f"\n[Calling {event['tool_name']}...]")
elif event_type == "tool_result":
print(f"[Tool returned result]")
elif event_type == "error":
print(f"\nError: {event['message']}")
elif event_type == "complete":
print("\nDone.")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify({ message: "What can you help me with?" }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const event = JSON.parse(line.slice(6));
switch (event.event) {
case "start":
console.log("Session:", event.session_id);
break;
case "chunk":
process.stdout.write(event.content);
break;
case "tool_call":
console.log(`\n[Calling ${event.tool_name}...]`);
break;
case "complete":
console.log("\nDone.");
break;
}
}
}
}
```
```bash cURL theme={null}
# Stream events to terminal (each line is a JSON event)
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "What can you help me with?"}'
```
## Orchestration Events
Orchestration streaming extends the agent event model with delegation events. You see the coordinator reasoning, delegating to entity agents, each entity's response, and the coordinator's final synthesis. Events like delegation\_start and entity\_chunk let you show which agent is currently working.
## Workflow Streaming
Workflow streaming sends per-block events as the engine traverses the DAG. The agent-block and orchestration-block streams interleave their own content\_delta / reasoning\_delta events directly into the workflow stream, so a downstream client can render the agent's tokens live while still seeing block-level structure:
| Event | Key Fields | When |
| ----------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `workflow_start` | `execution_id` | The workflow execution has begun |
| `block_started` | `block_id`, `block_type` | The engine is about to execute a block |
| `block_chunk` | `block_id`, `delta` | A streaming block (agent / orchestration) produced output |
| `content_delta` / `reasoning_delta` | `block_id`, `delta` | Forwarded from an agent/orchestration block inside the workflow — lets the UI render tokens live |
| `block_output` | `block_id`, `output` | A block finished with this output, which downstream blocks may reference |
| `block_completed` | `block_id` | A block has fully finished (including any post-processing) |
| `block_error` | `block_id`, `error` | A block failed — by default, the workflow short-circuits unless the next block is in an error-handler position |
| `workflow_complete` | `execution_id`, `result` | The whole execution finished |
| `workflow_error` | `execution_id`, `error` | The execution failed at the top level |
The block-level events let you build a step indicator that updates as the DAG advances, and the forwarded content/reasoning deltas let you stream the agent's response within the same UI surface.
**Buffer handling**
SSE data may arrive in partial chunks: a single read() call might contain half an event or multiple events. Always buffer incoming data and split on newlines to ensure you process complete events.
## Next Steps
Hands-on guide to consuming streaming events.
Create an agent with streaming support.
Streaming endpoint documentation.
# Workflows
Source: https://docs.powabase.ai/concepts/workflows-concept
Workflows are DAG-based automation pipelines that chain together LLM calls, code execution, conditions, and agent runs into deterministic, repeatable processes.
## What is a Workflow?
A Workflow is a directed acyclic graph (DAG) of blocks connected by edges. Each block performs a specific action: calling an LLM, running code, evaluating a condition, or executing an agent. Data flows from block to block through the edges. Unlike orchestrations (where the coordinator decides what happens), workflows follow a fixed, predetermined path every time.
## Block Types
The block registry accepts these canonical types (plus the back-compat aliases `function` for `code` and `api_call` for `general_api`):
| Block | Description | Key Config |
| ------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| starter | Manual / API trigger — declares the workflow's input variables | None (variables are passed at execution time) |
| webhook | HTTP trigger — exposes the workflow at `POST /api/webhooks/{webhook_id}` once deployed/armed | `webhook_id` (UUID, you mint it via API or the studio mints it for you), `webhook_secret` |
| agent | Runs an existing agent with a message | `agent_id`, `message` (template with `{{variables.x}}` interpolation) |
| orchestration | Delegates to a multi-agent coordinator | `orchestration_id`, `message` |
| code | Executes custom Python or JavaScript | Language, source, input mappings |
| condition | Branches the flow based on a boolean expression | Expression evaluated against upstream outputs |
| split | Parallel fan-out — runs downstream branches concurrently | Branch selection rules |
| platform\_api | Calls a platform resource (KB search, agent run, etc.) | Resource type + parameters |
| general\_api | Calls an external HTTP API | URL, method, headers, body template |
| response | Returns the workflow result back to the caller | Result template referencing upstream block outputs |
Unknown block types are rejected at `PUT /api/workflows/{id}/graph` with `400 Unknown block type`.
## Graph Execution
When you execute a workflow, the engine evaluates blocks in topological order. Each block receives the outputs of its upstream blocks as input. Condition blocks create branches, and only the matching branch continues execution. The workflow finishes when all output blocks have been reached.
]
## Programmatic vs Copilot
You can build workflows two ways. The programmatic approach uses the PUT /api/workflows/\{id}/graph endpoint to define blocks and edges as JSON. The Copilot approach uses natural language: describe what you want, and the AI copilot generates the workflow graph for you. Both produce the same underlying graph structure.
## Variables and references
Workflow blocks pass data to downstream blocks through their `output`. To reference an upstream block's output from another block's config, use **angle-bracket reference syntax**:
```
```
The resolver walks the dotted path against the upstream block's output dict. Block IDs may contain letters, digits, underscores, hyphens, and spaces, so `` is valid.
Two contexts:
* **Inside a string** (most block configs): every `` placeholder gets substituted as a string. `"Hello !"` becomes `"Hello Alice!"`.
* **Inside a JSON value** (e.g. `general_api` body, `code` inputs, agent block messages): if the entire string is one reference, the resolved value is returned **with its original type** (so you can pass a number, array, or object, not just a string). `""` returns `["foo", "bar"]` rather than `'["foo", "bar"]'`.
The starter block's variables surface as ``, and you'll also see the older `{{variables.x}}` syntax in agent / code block templates. Both work; the angle-bracket form is canonical and applies to every block.
Unresolvable references (block not yet run, missing field) leave the placeholder string as-is so you can see what failed when inspecting block logs.
For condition blocks, the expression evaluator uses a Python-safe AST subset: comparisons, BoolOp (`and`/`or`/`not`), BinOp (`+`/`-`/`*`/`/`), attribute access, and subscripting. Function calls are not permitted.
## Scheduling
When a workflow's starter block has `schedule_enabled: true` in its config, deploying the workflow materializes a `schedule_config` on the workflow row and Celery's scheduler tick (every 30 seconds) fires runs at the configured cadence.
The starter block's config takes:
| Field | Type | Notes |
| --------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `schedule_enabled` | bool | Must be `true` for any scheduling to happen |
| `schedule_type` | `"interval"` \| `"cron"` | Default `interval` |
| `schedule_timezone` | IANA name | Default `UTC` |
| `schedule_start_at` / `schedule_end_at` | ISO 8601 | Optional bounds; runs outside the window are skipped |
| `schedule_max_runs` | int | Optional cap; once reached, the workflow stops being scheduled |
| `schedule_interval_value` | int | For `interval` type. Minimum effective interval is **60 seconds** even if you set a smaller value |
| `schedule_interval_unit` | `"minutes"` \| `"hours"` \| `"days"` | For `interval` type. Default `minutes` |
| `schedule_cron` | cron expression | For `cron` type. Default `0 * * * *` (top of every hour). croniter syntax |
On `/deploy`, the platform reads these from the starter block, normalizes them into `schedule_config` on the workflow row, resets `schedule_run_count` to 0, and the tick worker picks it up. `/undeploy` clears `schedule_config` and stops further scheduled runs.
Scheduled runs execute as if invoked through `/execute`: the starter block's variables get whatever default values are configured (no per-run override is possible from the schedule itself).
## Deployment & Webhooks
Workflows expose two activation modes for their webhook block:
* **Deploy** (`POST /api/workflows/{id}/deploy`) sets `state = "deployed"`. The webhook accepts unlimited calls until you `undeploy`. Use this for production.
* **Arm** (`POST /api/workflows/{id}/arm`) leaves `state = "internal"` but opens a 10-minute window during which the webhook accepts exactly one call. After it fires (or expires), you must arm again. Use this for one-shot tests.
The `webhook_id` and `webhook_secret` live in the **webhook block's config**; they're stored on the block when you add it to the graph. The studio editor mints both client-side when you drag in a webhook block. Neither `/deploy` nor `/arm` returns secrets: `/deploy` returns `{"ok": true, "state": "deployed"}` and `/arm` returns `{"ok": true, "armed_until": ""}`. To retrieve credentials programmatically, fetch the workflow with `GET /api/workflows/{id}` and read them from the webhook block's config.
**Webhook security**
Each webhook block has a fixed `webhook_secret` set when the block was created; it does **not** rotate on arm/deploy. External callers include it as a `Bearer` token in the `Authorization` header (preferred) or as a `?token=` query parameter. There is no body-HMAC signature scheme and no replay-window beyond the 10-min arm TTL.
## Next Steps
Build a workflow step by step using the API.
Build a workflow using natural language.
Full endpoint documentation.
# Advanced Agent Configuration
Source: https://docs.powabase.ai/guides/advanced-agent-config
Configure MCP servers, hooks, and the human-in-the-loop approval flow for production-grade agents.
Beyond basic tools and knowledge bases, agents take three further configuration options: MCP (Model Context Protocol) servers for external tool integrations, hooks for triggering webhooks on agent lifecycle events, and an approval flow that pauses execution until a human approves sensitive tool calls. This guide walks through each one.
**Prerequisites:**
* An agent created (see Build an Agent guide)
Connect an external MCP server to your agent. The agent discovers and calls tools exposed by the MCP server over HTTP transport (default; SSE is also accepted) during runs.
**Endpoint:** `POST /api/agents/{id}/mcp-servers`
The agent connects to the MCP server at the start of each run to discover available tools. Tools are then available alongside builtin tools and knowledge base search.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/mcp-servers",
headers=headers,
json={
"name": "GitHub Tools",
"url": "https://mcp.example.com/github/mcp",
"transport": "http",
"headers": {
"Authorization": "Bearer ghp_your_token_here",
},
},
)
mcp_server = response.json()
print(f"MCP server added: {mcp_server['id']}")
print(f"Available tools will be discovered at runtime")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/mcp-servers`, {
method: "POST",
headers,
body: JSON.stringify({
name: "GitHub Tools",
url: "https://mcp.example.com/github/mcp",
transport: "http",
headers: {
Authorization: "Bearer ghp_your_token_here",
},
}),
});
const mcpServer = await response.json();
console.log("MCP server added:", mcpServer.id);
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{agent_id}/mcp-servers' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "GitHub Tools",
"url": "https://mcp.example.com/github/mcp",
"transport": "http",
"headers": {
"Authorization": "Bearer ghp_your_token_here"
}
}'
```
**Response:**
```json theme={null}
{
"id": "mcp-uuid",
"agent_id": "agent-uuid",
"name": "GitHub Tools",
"url": "https://mcp.example.com/github/mcp",
"transport": "http",
"headers": { "Authorization": "Bearer ..." },
"config": {},
"enabled": true,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}
```
Add an HTTP webhook hook that fires before each tool call. Use hooks to log tool usage, enforce policies, or notify external systems.
**Endpoint:** `POST /api/agents/{id}/hooks`
This example uses `PreToolUse`, but hooks fire at six lifecycle events (`OnRunStart`, `PreToolUse`, `OnDelegation`, `PostToolUse`, `PreResponse`, `OnRunComplete`) and come in three types (`http`, `rule`, `approval`). Use the optional `matcher` field to target a specific tool by name. See [Hooks & Middleware](/concepts/agents-tools#hooks--middleware) for the full event semantics and the webhook request/response contract.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/hooks",
headers=headers,
json={
"event": "PreToolUse",
"type": "http",
"config": {
"url": "https://your-app.com/webhooks/tool-calls",
},
},
)
hook = response.json()
print(f"Hook created: {hook['id']}")
print(f"Event: {hook['event']}, Type: {hook['type']}")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/hooks`, {
method: "POST",
headers,
body: JSON.stringify({
event: "PreToolUse",
type: "http",
config: {
url: "https://your-app.com/webhooks/tool-calls",
},
}),
});
const hook = await response.json();
console.log("Hook created:", hook.id);
console.log("Event:", hook.event, "Type:", hook.type);
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{agent_id}/hooks' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"event": "PreToolUse",
"type": "http",
"config": {"url": "https://your-app.com/webhooks/tool-calls"}
}'
```
Add an approval hook to the agent. When a tool call matches, the run pauses and emits an `approval_requested` SSE event. The run waits until you approve or reject via the API.
**Endpoint:** `POST /api/agents/{id}/hooks`
Set matcher to a specific tool name to only require approval for that tool, or omit matcher to require approval for all tool calls.
```python Python theme={null}
# Add an approval hook — pauses on database_query calls
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/hooks",
headers=headers,
json={
"event": "PreToolUse",
"type": "approval",
"matcher": "database_query",
"config": {"message": "Approve this database query?"},
},
)
print(f"Approval hook added: {response.json()['id']}")
# Stream a run — watch for approval_requested events
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json={"message": "Delete all inactive users from the database"},
stream=True,
)
import json
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if not text.startswith("data: "):
continue
event = json.loads(text[6:])
if event["event"] == "approval_requested":
print(f"Approval needed for: {event['tool_name']}")
print(f"Input: {json.dumps(event.get('tool_input', {}), indent=2)}")
print(f"Run ID: {event['run_id']}")
# The stream pauses here — approve or reject via the API
break
elif event["event"] == "chunk":
print(event["content"], end="")
```
```typescript TypeScript theme={null}
// Add an approval hook — pauses on database_query calls
await fetch(`${BASE_URL}/api/agents/${agentId}/hooks`, {
method: "POST",
headers,
body: JSON.stringify({
event: "PreToolUse",
type: "approval",
matcher: "database_query",
config: { message: "Approve this database query?" },
}),
});
// Stream a run and watch for approval_requested events
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify({ message: "Delete all inactive users from the database" }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
if (event.event === "approval_requested") {
console.log(`Approval needed for: ${event.tool_name}`);
console.log("Input:", JSON.stringify(event.tool_input ?? {}, null, 2));
console.log(`Run ID: ${event.run_id}`);
// Stream pauses here — approve or reject via the API
break;
}
if (event.event === "chunk") {
process.stdout.write(event.content);
}
}
}
```
```bash cURL theme={null}
# Add an approval hook for database_query
curl -X POST '{BASE_URL}/api/agents/{agent_id}/hooks' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"event": "PreToolUse",
"type": "approval",
"matcher": "database_query",
"config": {"message": "Approve this database query?"}
}'
# Stream a run — look for approval_requested events
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "Delete all inactive users from the database"}'
```
When an approval\_requested event pauses a run, call the approve endpoint to let execution continue, or reject to skip the tool call.
**Endpoint:** `POST /api/agents/runs/{run_id}/approve`
After approval, the SSE stream resumes and the tool executes normally. After rejection, the agent skips the tool call and may try a different approach or respond to the user directly.
```python Python theme={null}
# Approve the pending tool call
response = requests.post(
f"{BASE_URL}/api/agents/runs/{run_id}/approve",
headers=headers,
json={"approved": True},
)
print(f"Approved: {response.json()}")
# Or reject
response = requests.post(
f"{BASE_URL}/api/agents/runs/{run_id}/approve",
headers=headers,
json={"approved": False},
)
print(f"Rejected: {response.json()}")
```
```typescript TypeScript theme={null}
// Approve the pending tool call
const approveRes = await fetch(
`${BASE_URL}/api/agents/runs/${runId}/approve`,
{
method: "POST",
headers,
body: JSON.stringify({ approved: true }),
},
);
console.log("Approved:", await approveRes.json());
// Or reject
const rejectRes = await fetch(
`${BASE_URL}/api/agents/runs/${runId}/approve`,
{
method: "POST",
headers,
body: JSON.stringify({ approved: false }),
},
);
console.log("Rejected:", await rejectRes.json());
```
```bash cURL theme={null}
# Approve
curl -X POST '{BASE_URL}/api/agents/runs/{run_id}/approve' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"approved": true}'
# Reject
curl -X POST '{BASE_URL}/api/agents/runs/{run_id}/approve' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"approved": false}'
```
## What's Next
Understand the full tool system including MCP, builtins, and custom tools.
Coordinate multiple agents working together.
Full endpoint documentation for agents.
# ai schema recipes
Source: https://docs.powabase.ai/guides/ai-schema-recipes
Four worked examples that use PostgREST directly against ai.* tables: custom hybrid search, usage analytics, bulk tagging, cross-KB joins.
These recipes assume you've read [Querying the ai schema via PostgREST](/concepts/ai-schema-postgrest) and have the Service Role (Secret) Key from the Connect modal. Every example uses `Accept-Profile: ai` (read) or `Content-Profile: ai` (write) to target the `ai` schema instead of the default `public`.
**Use the Service Role key from a trusted backend for these recipes.** They all touch project-wide data and are not RLS-safe for direct browser access. If you want to expose any of this to end users, gate it behind your own API.
## Recipe 1: Custom hybrid search over ai.chunks
The typed `POST /api/knowledge-bases/{id}/search` runs the platform's hybrid retriever for you. If you want different filters, different ranking, or to merge results across multiple KBs in one query, drop down to direct PostgREST.
This recipe fetches chunks across two KBs, scoped to a single source's metadata tag, ordered by an extra score column you've populated yourself.
```python Python theme={null}
import requests
KB_IDS = ["kb-uuid-1", "kb-uuid-2"]
TAG = "policy"
response = requests.get(
f"{BASE_URL}/rest/v1/chunks",
headers={
**headers,
"Accept-Profile": "ai",
},
params={
"select": "id,text,score,source_id,meta,knowledge_base_id",
"knowledge_base_id": f"in.({','.join(KB_IDS)})",
"meta->>tag": f"eq.{TAG}",
"order": "score.desc",
"limit": 20,
},
)
results = response.json()
for row in results:
print(f"[KB {row['knowledge_base_id'][:8]}] score={row['score']:.3f}")
print(row["text"][:200])
```
```typescript TypeScript theme={null}
const kbIds = ["kb-uuid-1", "kb-uuid-2"];
const tag = "policy";
const params = new URLSearchParams({
select: "id,text,score,source_id,meta,knowledge_base_id",
knowledge_base_id: `in.(${kbIds.join(",")})`,
"meta->>tag": `eq.${tag}`,
order: "score.desc",
limit: "20",
});
const res = await fetch(`${BASE_URL}/rest/v1/chunks?${params}`, {
headers: { ...headers, "Accept-Profile": "ai" },
});
const results = await res.json();
for (const row of results) {
console.log(`[KB ${row.knowledge_base_id.slice(0, 8)}] score=${row.score.toFixed(3)}`);
console.log(row.text.slice(0, 200));
}
```
```bash cURL theme={null}
curl -G '{BASE_URL}/rest/v1/chunks' \
-H "Accept-Profile: ai" \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
--data-urlencode 'select=id,text,score,source_id,meta,knowledge_base_id' \
--data-urlencode 'knowledge_base_id=in.(kb-uuid-1,kb-uuid-2)' \
--data-urlencode 'meta->>tag=eq.policy' \
--data-urlencode 'order=score.desc' \
--data-urlencode 'limit=20'
```
Two PostgREST patterns to notice. The `in.(...)` filter selects across multiple KBs in one query, with no application-side fan-out. The `meta->>tag=eq.policy` selector reaches into the `meta` JSONB column with the standard Postgres `->>` operator; PostgREST honors that syntax in filter expressions.
## Recipe 2: Usage analytics on agent\_runs
Build a dashboard query that shows, for each agent, the run count, average input/output token counts, and last-run timestamp over the last 7 days. The typed `/api/agents` doesn't aggregate; PostgREST + a server-side RPC does it in one round-trip.
The simplest path is to define a SQL view or a Postgres function on `ai`, then call it via `POST /rest/v1/rpc/{function}`. Here's the function:
```sql theme={null}
CREATE OR REPLACE FUNCTION ai.agent_usage_last_7d()
RETURNS TABLE (
agent_id uuid,
agent_name text,
run_count bigint,
avg_input_tokens numeric,
avg_output_tokens numeric,
last_run_at timestamptz
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ai
AS $$
SELECT
a.id,
a.name,
count(r.id) AS run_count,
avg(r.input_tokens) AS avg_input_tokens,
avg(r.output_tokens) AS avg_output_tokens,
max(r.created_at) AS last_run_at
FROM ai.agents a
LEFT JOIN ai.agent_runs r
ON r.agent_id = a.id
AND r.created_at >= now() - interval '7 days'
GROUP BY a.id, a.name
ORDER BY run_count DESC;
$$;
GRANT EXECUTE ON FUNCTION ai.agent_usage_last_7d() TO service_role, authenticated;
```
Call it from your app:
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/rest/v1/rpc/agent_usage_last_7d",
headers={**headers, "Accept-Profile": "ai"},
json={},
)
for row in response.json():
print(
f"{row['agent_name']:30s} "
f"runs={row['run_count']:4d} "
f"avg_in={row['avg_input_tokens']:8.0f} "
f"avg_out={row['avg_output_tokens']:8.0f}"
)
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/rest/v1/rpc/agent_usage_last_7d`, {
method: "POST",
headers: { ...headers, "Accept-Profile": "ai" },
body: "{}",
});
const rows = await res.json();
for (const row of rows) {
console.log(
`${row.agent_name.padEnd(30)} runs=${String(row.run_count).padStart(4)} ` +
`avg_in=${row.avg_input_tokens?.toFixed(0).padStart(8)} ` +
`avg_out=${row.avg_output_tokens?.toFixed(0).padStart(8)}`
);
}
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/rest/v1/rpc/agent_usage_last_7d' \
-H "Accept-Profile: ai" \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{}'
```
`SECURITY DEFINER` runs the function with the owner's permissions rather than the caller's. That fits here: the aggregate doesn't need per-user filtering, and you want the same results regardless of which key calls it. Set `search_path = ai` explicitly to avoid the [search-path SECURITY DEFINER pitfall](https://www.postgresql.org/docs/current/sql-createfunction.html#SQL-CREATEFUNCTION-SECURITY).
## Recipe 3: Bulk-tag sources
You imported 200 sources last month and want to attach a `{"tag": "q3-2025"}` JSONB key to all of them in one round-trip. The typed `PATCH /api/sources/{id}` is one-source-per-call; PostgREST supports a single filter+update.
```python Python theme={null}
SOURCE_IDS = ["uuid-1", "uuid-2", "..."] # 200 of these
response = requests.patch(
f"{BASE_URL}/rest/v1/sources",
headers={
**headers,
"Content-Profile": "ai",
"Prefer": "return=representation",
},
params={"id": f"in.({','.join(SOURCE_IDS)})"},
json={"meta": {"tag": "q3-2025"}},
)
print(f"Updated {len(response.json())} sources")
```
```typescript TypeScript theme={null}
const sourceIds = ["uuid-1", "uuid-2" /* ... */];
const res = await fetch(
`${BASE_URL}/rest/v1/sources?id=in.(${sourceIds.join(",")})`,
{
method: "PATCH",
headers: {
...headers,
"Content-Profile": "ai",
Prefer: "return=representation",
},
body: JSON.stringify({ meta: { tag: "q3-2025" } }),
},
);
const updated = await res.json();
console.log(`Updated ${updated.length} sources`);
```
```bash cURL theme={null}
curl -X PATCH '{BASE_URL}/rest/v1/sources?id=in.(uuid-1,uuid-2,...)' \
-H "Content-Profile: ai" \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"meta": {"tag": "q3-2025"}}'
```
Two notes. `Prefer: return=representation` makes PostgREST return the updated rows so you can confirm the count. **Beware:** the `meta` write here **replaces** the whole JSONB column rather than merging. For partial-update semantics, use `jsonb_set` via an RPC, or read-modify-write per row.
## Recipe 4: Cross-KB analytics with embeds
For a "which sources contribute most to this KB" report, you want each `indexed_sources` row joined to its `sources` row (for the name and file type) plus a chunk count. PostgREST's embed syntax does this in one request:
```python Python theme={null}
KB_ID = "kb-uuid"
response = requests.get(
f"{BASE_URL}/rest/v1/indexed_sources",
headers={**headers, "Accept-Profile": "ai"},
params={
"select": "id,index_status,source:sources(name,file_type),chunks(count)",
"knowledge_base_id": f"eq.{KB_ID}",
"order": "chunks.count.desc",
},
)
for row in response.json():
s = row["source"]
n = row["chunks"][0]["count"] if row["chunks"] else 0
print(f"{s['name']:50s} ({s['file_type']}) — {n} chunks, {row['index_status']}")
```
```typescript TypeScript theme={null}
const kbId = "kb-uuid";
const params = new URLSearchParams({
select: "id,index_status,source:sources(name,file_type),chunks(count)",
knowledge_base_id: `eq.${kbId}`,
order: "chunks.count.desc",
});
const res = await fetch(`${BASE_URL}/rest/v1/indexed_sources?${params}`, {
headers: { ...headers, "Accept-Profile": "ai" },
});
for (const row of await res.json()) {
const s = row.source;
const n = row.chunks?.[0]?.count ?? 0;
console.log(`${s.name.padEnd(50)} (${s.file_type}) — ${n} chunks, ${row.index_status}`);
}
```
```bash cURL theme={null}
curl -G '{BASE_URL}/rest/v1/indexed_sources' \
-H "Accept-Profile: ai" \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
--data-urlencode 'select=id,index_status,source:sources(name,file_type),chunks(count)' \
--data-urlencode 'knowledge_base_id=eq.kb-uuid' \
--data-urlencode 'order=chunks.count.desc'
```
The embed syntax `source:sources(name,file_type)` aliases the joined `sources` row as `source` in the response. `chunks(count)` does a count-only embed: PostgREST recognizes `count` as a special function and emits `[{"count": N}]` rather than the rows themselves. You can stack embeds, embed embeds, and filter on them. See the [PostgREST reference](/api-reference/postgrest) for the full filter and embed grammar.
## Next steps
The framing and tables behind these recipes.
Five tightening patterns when "all authenticated users see everything" isn't what you want.
Filter operators, embeds, headers, and the response/error contract.
What changes when you connect through PgBouncer's transaction-mode pooler instead.
# Auth & Connection
Source: https://docs.powabase.ai/guides/auth-connection
Find your project's credentials in the Connect modal, pick the right key for each surface, and make your first authenticated request.
Every Powabase project keeps its connection details in one place: the **Connect modal** in the Studio. It holds the Project URL, both API keys (Anon and Service Role), the JWT Secret, the Database URL, and ready-to-paste Postgres connection strings in nine driver formats. Open it once, copy what you need, and you can call any part of the platform.
## Open the Connect modal
In the Studio at [app.powabase.ai](https://app.powabase.ai), click the **Connect** button in the top-right of your project header.
The dialog has two tabs. **API Keys** lists everything you need to authenticate HTTP and Postgres clients. **Connection Strings** ships pre-filled snippets for psql, the generic URI, Node.js (pg), Python (psycopg2), Go (database/sql), JDBC, .NET, PHP, and SQLAlchemy: nine in all. Copy buttons sit next to every value. Secret fields hide by default; click the eye icon to reveal.
You can also open the modal by appending `?showConnect=true` to any project URL, which is handy for deep-linking from internal docs.
## What's in the modal, and where to use each value
| Field | Use it for | Safe to ship to clients? |
| ----------------------------- | ------------------------------------------------------------------------------------ | ------------------------ |
| **Project URL** | The `BASE_URL` for every HTTP call — `/api/*`, `/rest/v1/*`, `/auth/*`, `/storage/*` | Yes |
| **Anon (Publishable) Key** | Client-side calls to PostgREST and Storage that respect Row Level Security | Yes |
| **Service Role (Secret) Key** | Server-side calls to `/api/*` (AI surface) and any RLS-bypassing PostgREST access | **No — server only** |
| **JWT Secret** | Verifying user-signed JWTs on your own backend | **No — server only** |
| **Database URL** | Direct Postgres access (psql, migrations, ORMs, BI tools) | **No — server only** |
The rest of this guide uses the **Service Role (Secret) Key** as `API_KEY`. It authenticates every endpoint under `/api/*`, which is what most of the platform docs assume.
Never expose the Service Role key, JWT Secret, or Database URL to a browser, mobile app, or any environment outside your control. The Anon key is the only field in the modal safe to bundle into a client.
## Use it from code
Every request to /api/\* and /rest/v1/\* needs **both** an `apikey` header and an `Authorization: Bearer` header, set to the same key. Use the Service Role (Secret) Key for /api/\* and server-side PostgREST.
**Endpoint:** `Headers: apikey + Authorization`
Sending only one of the two headers is the most common cause of 401 errors. PostgREST and Kong both reject the request.
```python Python theme={null}
import requests
BASE_URL = "{BASE_URL}" # Connect modal -> Project URL
API_KEY = "{API_KEY}" # Connect modal -> Service Role (Secret) Key
headers = {
"apikey": API_KEY,
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
```
```typescript TypeScript theme={null}
const BASE_URL = "{BASE_URL}"; // Connect modal -> Project URL
const API_KEY = "{API_KEY}"; // Connect modal -> Service Role (Secret) Key
const headers = {
apikey: API_KEY,
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};
```
```bash cURL theme={null}
# BASE_URL -> Project URL from the Connect modal
# API_KEY -> Service Role (Secret) Key from the Connect modal
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json"
```
Hit a cheap, idempotent endpoint to confirm the credentials work end-to-end. An empty array and a populated array both count as success.
**Endpoint:** `GET /api/agents`
```python Python theme={null}
response = requests.get(
f"{BASE_URL}/api/agents",
headers=headers,
)
print(response.json())
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents`, { headers });
const agents = await response.json();
console.log(agents);
```
```bash cURL theme={null}
curl '{BASE_URL}/api/agents' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
```
**Response:**
```json theme={null}
{
"agents": [
{
"id": "uuid-here",
"name": "My Agent",
"model": "gpt-4o",
"created_at": "2026-01-01T00:00:00Z"
}
],
"total": 1,
"limit": 50,
"offset": 0
}
```
For migrations, dashboards, ORMs, or any tool that speaks the Postgres wire protocol, grab the **Database URL** from the API Keys tab, or open the **Connection Strings** tab and copy a pre-built snippet in your language. Use this from servers and trusted environments only.
**Endpoint:** `Postgres wire protocol`
The Database URL embeds the database password in cleartext. Treat it like the Service Role key: never commit it, never expose it client-side, and rotate the database password (Studio → Settings → Database) if it leaks.
```python Python theme={null}
# Python (psycopg2) — from Connection Strings -> Python
import psycopg2
conn = psycopg2.connect("{POSTGRES_URL}") # Database URL from the Connect modal
with conn.cursor() as cur:
cur.execute("select count(*) from public.users")
print(cur.fetchone())
```
```typescript TypeScript theme={null}
// Node.js (pg) — from Connection Strings -> Node.js
import { Client } from "pg";
const client = new Client({ connectionString: "{POSTGRES_URL}" }); // Database URL
await client.connect();
const { rows } = await client.query("select count(*) from public.users");
console.log(rows);
```
```bash cURL theme={null}
# psql — from Connection Strings -> PSQL
psql "{POSTGRES_URL}"
```
## What's Next
Build an end-to-end RAG agent in 5 minutes.
AI schema vs public schema, plus PostgREST and direct Postgres usage.
How Kong routes /api/*, /rest/v1/*, /auth/*, and /storage/* to your project's stack.
# OAuth providers
Source: https://docs.powabase.ai/guides/auth-oauth-providers
Wire up Google, GitHub, Apple, and 19 other OAuth providers with PKCE. Configuration, redirect URLs, the sign-in flow, and the gotchas that bite people.
GoTrue ships with 22 OAuth providers wired up, all disabled by default. Enabling one is a configuration change plus app code to drive the redirect flow. This guide covers the full sequence for Google and GitHub (the two most common), points you at the per-provider docs for the rest, and explains how PKCE works for browser and mobile clients.
For the auth foundation, see [Auth model](/concepts/auth-model). For email/password and magic-link flows, see [Signup, signin, magic link](/guides/auth-signup-signin).
## Supported providers
The 22 OAuth providers GoTrue ships with on Powabase:
`apple` · `azure` · `bitbucket` · `discord` · `facebook` · `figma` · `github` · `gitlab` · `google` · `kakao` · `keycloak` · `linkedin_oidc` · `notion` · `slack` · `slack_oidc` · `spotify` · `twitch` · `twitter` · `workos` · `zoom`
All of them are off by default. You enable them with provider-specific environment variables (Helm overrides for self-hosted, or the Studio's auth settings for managed). The pattern for any provider is:
```yaml theme={null}
gotrue:
providers:
google:
enabled: "true"
clientId: ""
secret: ""
```
A few providers (`azure`, `gitlab`, `keycloak`, `workos`) also need a `url` field for their issuer endpoint, which matters when the provider is self-hosted or you're on a non-default tenant.
## The redirect flow at a glance
1. Your app calls `GET /auth/v1/authorize?provider=google&redirect_to=`.
2. GoTrue 302-redirects the browser to Google's consent screen with the client\_id, scopes, and a state parameter it generated.
3. The user authorizes; Google redirects back to GoTrue's callback URL (`/auth/v1/callback`) with an authorization code.
4. GoTrue exchanges the code with Google for the user's profile, creates/updates the user record, and 302-redirects the browser to your `redirect_to` URL with a session code in the URL fragment.
5. Your callback page extracts the tokens from the URL fragment (or, in PKCE mode, exchanges the code for tokens) and persists them.
Most of this happens behind the scenes. Your code is only step 1 (kick off the flow) and step 5 (handle the callback).
## Setting up Google
Three pieces: register a Google Cloud OAuth client, configure GoTrue, write the app code.
### 1. Google Cloud setup
In the [Google Cloud Console](https://console.cloud.google.com/apis/credentials), create an OAuth 2.0 Client ID. The fields that matter:
* **Application type:** Web application.
* **Authorized JavaScript origins:** the origin of your app (e.g., `https://your-app.example.com`).
* **Authorized redirect URIs:** `https://{ref}.p.powabase.ai/auth/v1/callback`. This is GoTrue's callback, **not** your app's. Google will call GoTrue, GoTrue will call your app.
Note the **client ID** and **client secret**.
### 2. Enable in Powabase
For managed cloud, set `Google Client ID` and `Google Client Secret` in the Studio's Authentication settings. For self-hosted:
```yaml theme={null}
gotrue:
providers:
google:
enabled: "true"
clientId: "your-google-client-id.apps.googleusercontent.com"
secret: "GOCSPX-your-client-secret"
# Make sure your app's callback is in the allow list:
uriAllowList: "https://your-app.example.com/auth/callback,http://localhost:3000/auth/callback"
```
Apply the Helm release and GoTrue will restart with Google enabled.
### 3. App code
```typescript TypeScript theme={null}
const BASE_URL = "https://{ref}.p.powabase.ai";
const ANON_KEY = "";
// Step 1: Kick off the OAuth flow
function signInWithGoogle() {
const redirectTo = encodeURIComponent("https://your-app.example.com/auth/callback");
window.location.href = `${BASE_URL}/auth/v1/authorize?provider=google&redirect_to=${redirectTo}`;
}
// Step 5: On the /auth/callback page, handle the redirect from GoTrue
// Tokens come back in the URL fragment, not the query string.
function handleCallback() {
const params = new URLSearchParams(window.location.hash.slice(1));
const access_token = params.get("access_token");
const refresh_token = params.get("refresh_token");
if (access_token && refresh_token) {
localStorage.setItem("powabase_access_token", access_token);
localStorage.setItem("powabase_refresh_token", refresh_token);
window.location.hash = ""; // clean the URL
window.location.replace("/"); // navigate to your app
} else {
const error = params.get("error_description") ?? "unknown";
console.error("Sign-in failed:", error);
}
}
```
```python Python theme={null}
# OAuth is intrinsically a browser flow — there's no clean way to drive it
# entirely from a server. If you need server-side sign-in, use email +
# password (see guides/auth-signup-signin) or roll a custom flow with the
# admin API.
#
# What you can do server-side: render an HTML page with the sign-in button:
authorize_url = f"{BASE_URL}/auth/v1/authorize?provider=google&redirect_to={your_callback_url}"
print(f'Sign in with Google')
```
```bash cURL theme={null}
# OAuth flows happen in the browser, not via cURL. You can fetch the
# /authorize endpoint to confirm the redirect URL works, but the
# actual sign-in needs a browser to follow the redirects.
curl -I 'https://{ref}.p.powabase.ai/auth/v1/authorize?provider=google&redirect_to=https://your-app.example.com/auth/callback' \
-H "apikey: "
# Expected: 302 with a Location: header pointing to Google.
```
## Setting up GitHub
Same shape, different provider console.
### 1. GitHub OAuth app setup
In [GitHub Developer Settings](https://github.com/settings/developers), create a new OAuth App:
* **Homepage URL:** your app's URL.
* **Authorization callback URL:** `https://{ref}.p.powabase.ai/auth/v1/callback`. GoTrue's callback, not your app's.
Note the **client ID** and generate a **client secret**.
### 2. Enable in Powabase
```yaml theme={null}
gotrue:
providers:
github:
enabled: "true"
clientId: "Iv1.xxx"
secret: "ghp_xxx"
uriAllowList: "https://your-app.example.com/auth/callback"
```
### 3. App code
Same as Google, just change `provider=google` to `provider=github`:
```typescript theme={null}
window.location.href = `${BASE_URL}/auth/v1/authorize?provider=github&redirect_to=${redirectTo}`;
```
## Requesting additional scopes
By default, GoTrue requests the minimum scopes needed to identify the user (typically email + profile). To request more, say to read a GitHub user's repos or send email through Gmail, pass `scopes` as a query parameter:
```typescript theme={null}
const scopes = encodeURIComponent("repo read:org");
window.location.href =
`${BASE_URL}/auth/v1/authorize?provider=github&scopes=${scopes}&redirect_to=${redirectTo}`;
```
The provider's access token (the one that lets you call Google or GitHub APIs on the user's behalf) lands in `app_metadata.provider_token` on the user record. Read it from `auth.jwt() -> 'app_metadata' ->> 'provider_token'` in SQL, or from the `user` object in your client SDK.
GoTrue does **not** refresh provider tokens for you. If you need long-lived API access to the upstream provider, you'll need to handle the refresh against that provider's token endpoint yourself.
## PKCE: when and why
For public clients (browser SPAs, mobile apps), the standard OAuth flow has a vulnerability: the authorization code is transmitted via URL parameters back to your app, and any local code that intercepts that URL can exchange the code for tokens. PKCE (Proof Key for Code Exchange) fixes this by having your client generate a one-time secret at the start of the flow and prove possession of it during the code exchange. An intercepted code is useless without the secret.
GoTrue supports PKCE automatically when you pass a `code_challenge` parameter to `/auth/v1/authorize`. Most client SDKs (e.g., `supabase-js`) handle PKCE transparently. If you're rolling your own client:
```typescript theme={null}
// Generate a code verifier (random 43-128 char string)
function generateCodeVerifier(): string {
const arr = new Uint8Array(32);
crypto.getRandomValues(arr);
return btoa(String.fromCharCode(...arr))
.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
// Hash it to get the challenge
async function deriveChallenge(verifier: string): Promise {
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest("SHA-256", data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
async function signInWithPkce() {
const verifier = generateCodeVerifier();
const challenge = await deriveChallenge(verifier);
// Persist the verifier so we can use it on the callback page
sessionStorage.setItem("pkce_verifier", verifier);
const params = new URLSearchParams({
provider: "google",
redirect_to: "https://your-app.example.com/auth/callback",
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.href = `${BASE_URL}/auth/v1/authorize?${params}`;
}
// On the callback page:
async function handlePkceCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const verifier = sessionStorage.getItem("pkce_verifier");
if (!code || !verifier) {
console.error("Missing code or verifier");
return;
}
const res = await fetch(`${BASE_URL}/auth/v1/token?grant_type=pkce`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${ANON_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ auth_code: code, code_verifier: verifier }),
});
const result = await res.json();
sessionStorage.removeItem("pkce_verifier");
// Persist tokens as usual
}
```
When you use PKCE, GoTrue returns the auth code in the query string (not the URL fragment), and your app explicitly exchanges it. When you don't use PKCE, GoTrue returns tokens directly in the URL fragment after the OAuth dance is complete.
For browser SPAs, **use PKCE.** For server-rendered apps where the callback is server-side and tokens never touch the browser, the standard flow is fine.
## The redirect-allow-list gotcha
The `gotrue.uriAllowList` is a comma-separated list of URLs GoTrue will redirect to. **If your `redirect_to` isn't in the list, GoTrue silently drops it** and redirects to `gotrue.siteUrl` instead, which on a fresh project is unset, so you end up on the GoTrue host with no obvious error.
Set the allow list to include every callback URL your app might use:
```yaml theme={null}
gotrue:
siteUrl: "https://your-app.example.com"
uriAllowList: "https://your-app.example.com/auth/callback,http://localhost:3000/auth/callback,http://localhost:3000/auth/reset-password"
```
For local development, include the localhost variants. Don't add wildcards; GoTrue does substring matching, not glob.
## Common failure modes
* **"redirect\_uri\_mismatch" from the provider.** The callback URL you registered with Google/GitHub doesn't match `https://{ref}.p.powabase.ai/auth/v1/callback`. Update the provider's callback URL.
* **Redirect lands on GoTrue host, not your app.** Your `redirect_to` isn't in the `uriAllowList`. Add it.
* **`error=server_error` with no detail.** GoTrue couldn't exchange the code with the provider. Most often: the provider's client secret is wrong in your Helm config, or the provider has rate-limited your client ID. Check the GoTrue pod logs.
* **User signs in but lands without `app_metadata.provider_token`.** Some providers need explicit scopes to return their token (e.g., Google's `offline_access`). Pass `scopes=offline_access` in the `/authorize` query.
## Next steps
Email/password and magic-link flows as the alternative to OAuth.
What's inside the JWTs you get back from these flows.
Full /auth/v1/\* endpoint surface, including the OAuth-specific endpoints.
How to use OAuth-provided claims (provider, provider\_id) in RLS policies.
# Signup, signin, magic link
Source: https://docs.powabase.ai/guides/auth-signup-signin
Working end-to-end auth flows: email + password signup and signin, magic link, password recovery, and session refresh. In Python, TypeScript, and cURL.
These flows assume you've read [Auth model](/concepts/auth-model) and have your Anon (Publishable) Key from the Connect modal. **All calls here use the Anon Key** as the `apikey` header; that's what makes the auth endpoints reachable from public clients. After sign-in, you send the user's access token as the `Authorization: Bearer ` header on subsequent API calls (and keep the Anon Key in `apikey`).
For the conceptual underpinning, see [Auth model](/concepts/auth-model). For the full endpoint surface, see [Auth Reference](/api-reference/auth). For OAuth flows specifically, see [OAuth providers](/guides/auth-oauth-providers).
## Headers used throughout
Every request below uses these two headers (the auth API doesn't need its own access token; that's what you're trying to get):
```
apikey:
Authorization: Bearer
```
After sign-in, replace the `Authorization` value with the user's `access_token`. Keep `apikey` as the Anon Key; PostgREST and GoTrue both check it for upstream routing.
## Email + password: signup
`POST /auth/v1/signup` creates a new user with email + password and, on default settings (`autoConfirm: true`), returns a session immediately. On projects where you've enabled email confirmation, the response is just `{ user }` and the user has to click the email link before they can sign in.
```python Python theme={null}
import requests
ANON_KEY = ""
BASE_URL = "https://{ref}.p.powabase.ai"
response = requests.post(
f"{BASE_URL}/auth/v1/signup",
headers={
"apikey": ANON_KEY,
"Authorization": f"Bearer {ANON_KEY}",
"Content-Type": "application/json",
},
json={
"email": "alice@example.com",
"password": "correcthorsebatterystaple",
},
)
result = response.json()
# autoConfirm: true → session present
if "access_token" in result:
access_token = result["access_token"]
refresh_token = result["refresh_token"]
print(f"Signed in. User id: {result['user']['id']}")
else:
# autoConfirm: false → user must verify email first
print(f"User created, verification email sent. User id: {result['id']}")
```
```typescript TypeScript theme={null}
const ANON_KEY = "";
const BASE_URL = "https://{ref}.p.powabase.ai";
const res = await fetch(`${BASE_URL}/auth/v1/signup`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${ANON_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "alice@example.com",
password: "correcthorsebatterystaple",
}),
});
const result = await res.json();
if (result.access_token) {
// Persist these — see "Storing tokens" below
localStorage.setItem("powabase_access_token", result.access_token);
localStorage.setItem("powabase_refresh_token", result.refresh_token);
console.log("Signed in as", result.user.email);
} else {
console.log("Confirmation email sent. User id:", result.id);
}
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/auth/v1/signup' \
-H "apikey: " \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "correcthorsebatterystaple"}'
```
**Optional fields you can include in the body:**
* `data: { ... }` — populates `user_metadata`. Use for display name, signup source, marketing consent, etc.
* `phone: "+1..."` — phone-based signup (requires `GOTRUE_EXTERNAL_PHONE_ENABLED=true` + Twilio configured).
* `gotrue_meta_security: { captcha_token: "..." }` — required if CAPTCHA is enabled on the project.
## Email + password: signin
`POST /auth/v1/token?grant_type=password` exchanges credentials for an access token + refresh token.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/auth/v1/token",
headers={
"apikey": ANON_KEY,
"Authorization": f"Bearer {ANON_KEY}",
"Content-Type": "application/json",
},
params={"grant_type": "password"},
json={
"email": "alice@example.com",
"password": "correcthorsebatterystaple",
},
)
result = response.json()
if response.status_code == 200:
access_token = result["access_token"]
refresh_token = result["refresh_token"]
user = result["user"]
else:
# 400: invalid_grant (wrong credentials), email_not_confirmed, etc.
print(f"Sign-in failed: {result.get('error_description')}")
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${ANON_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "alice@example.com",
password: "correcthorsebatterystaple",
}),
});
const result = await res.json();
if (res.ok) {
localStorage.setItem("powabase_access_token", result.access_token);
localStorage.setItem("powabase_refresh_token", result.refresh_token);
} else {
console.error("Sign-in failed:", result.error_description);
}
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/auth/v1/token?grant_type=password' \
-H "apikey: " \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "password": "correcthorsebatterystaple"}'
```
**Common error responses:**
* `400 invalid_grant`: wrong email/password combination, or the user doesn't exist.
* `400 email_not_confirmed`: autoConfirm is off and the user hasn't clicked the verification email yet.
* `400 over_email_send_rate_limit`: too many recent failed signin attempts from this IP (30/hour by default).
## Magic link (passwordless email)
`POST /auth/v1/otp` sends an email containing a magic link. The user clicks it, GoTrue verifies the token in the link, redirects to your app with a code in the URL, and your app calls `POST /auth/v1/token?grant_type=pkce` to exchange that code for the session.
The link flow takes two steps in your app: (1) request the magic link, (2) handle the redirect callback.
```python Python theme={null}
# Step 1: Request the magic link
response = requests.post(
f"{BASE_URL}/auth/v1/otp",
headers={
"apikey": ANON_KEY,
"Authorization": f"Bearer {ANON_KEY}",
"Content-Type": "application/json",
},
json={
"email": "alice@example.com",
"create_user": True, # signs up the user if they don't exist
"options": {
"email_redirect_to": "https://your-app.example.com/auth/callback",
},
},
)
# 200 OK with empty body means the email was sent.
# Step 2: Handle the redirect (server-side example — usually you'd do this in a
# browser route handler that parses the URL fragment from window.location)
# The redirect URL will look like:
# https://your-app.example.com/auth/callback#access_token=...&refresh_token=...
# Tokens are in the URL fragment, not the query string, so they don't hit your
# server logs. Parse them client-side and persist.
```
```typescript TypeScript theme={null}
// Step 1: Request the magic link
await fetch(`${BASE_URL}/auth/v1/otp`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${ANON_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "alice@example.com",
create_user: true,
options: { email_redirect_to: "https://your-app.example.com/auth/callback" },
}),
});
// 200 OK with empty body means the email was sent.
// Step 2: In your /auth/callback route in the browser
// Tokens come back in the URL fragment, not the query string.
const params = new URLSearchParams(window.location.hash.slice(1));
const access_token = params.get("access_token");
const refresh_token = params.get("refresh_token");
if (access_token && refresh_token) {
localStorage.setItem("powabase_access_token", access_token);
localStorage.setItem("powabase_refresh_token", refresh_token);
window.location.hash = ""; // clean up the URL
}
```
```bash cURL theme={null}
# Step 1 only — the redirect happens in a browser
curl -X POST 'https://{ref}.p.powabase.ai/auth/v1/otp' \
-H "apikey: " \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"email": "alice@example.com",
"create_user": true,
"options": {"email_redirect_to": "https://your-app.example.com/auth/callback"}
}'
```
**Two things to know:**
* **`create_user: false`** lets you implement "magic link only for existing users" (returns 400 if no user with that email).
* **Your redirect URL must be in the project's URI allow list** (`gotrue.uriAllowList` in Helm overrides, or the auth settings in the Studio). GoTrue won't redirect to arbitrary URLs.
## Password recovery
The recovery flow looks just like magic link. Request a recovery email, the user clicks it, and your callback grabs tokens from the URL fragment.
```python Python theme={null}
requests.post(
f"{BASE_URL}/auth/v1/recover",
headers={
"apikey": ANON_KEY,
"Authorization": f"Bearer {ANON_KEY}",
"Content-Type": "application/json",
},
json={
"email": "alice@example.com",
"options": {
"redirect_to": "https://your-app.example.com/auth/reset-password",
},
},
)
# Then on /auth/reset-password, the user is signed in (tokens in URL fragment).
# Have them enter a new password and call:
# PUT /auth/v1/user with Authorization: Bearer and body
# {"password": ""}
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/recover`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${ANON_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "alice@example.com",
options: { redirect_to: "https://your-app.example.com/auth/reset-password" },
}),
});
// Then on /auth/reset-password, after the user enters their new password:
await fetch(`${BASE_URL}/auth/v1/user`, {
method: "PUT",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ password: newPassword }),
});
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/auth/v1/recover' \
-H "apikey: " \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "options": {"redirect_to": "https://your-app.example.com/auth/reset-password"}}'
```
The `POST /auth/v1/recover` always returns 200, even for emails that don't exist. That's intentional: it stops an attacker from enumerating user accounts via the recovery endpoint.
## Refreshing the access token
Access tokens last 1 hour. Before yours expires, exchange the refresh token for a new pair:
```python Python theme={null}
def refresh_session(refresh_token: str) -> dict:
response = requests.post(
f"{BASE_URL}/auth/v1/token",
headers={
"apikey": ANON_KEY,
"Authorization": f"Bearer {ANON_KEY}",
"Content-Type": "application/json",
},
params={"grant_type": "refresh_token"},
json={"refresh_token": refresh_token},
)
if response.status_code == 400:
# invalid_grant: token already used, expired, or doesn't exist.
# Treat as session lost; re-authenticate from scratch.
raise SessionExpired()
return response.json() # {access_token, refresh_token, expires_in, ...}
```
```typescript TypeScript theme={null}
async function refreshSession(refreshToken: string) {
const res = await fetch(`${BASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${ANON_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (res.status === 400) {
// Session lost — re-authenticate
throw new Error("SESSION_EXPIRED");
}
const result = await res.json();
localStorage.setItem("powabase_access_token", result.access_token);
localStorage.setItem("powabase_refresh_token", result.refresh_token);
return result;
}
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/auth/v1/token?grant_type=refresh_token' \
-H "apikey: " \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"refresh_token": "v1.MzQ1..."}'
```
**Rotation is on by default.** Each refresh token is single-use; the response includes a fresh refresh token that must replace the old one in storage. The 10-second grace window means concurrent refresh attempts from the same client (a common SPA race) don't both fail.
**Schedule the refresh before expiry, not after.** A common pattern is to schedule a refresh at `expires_at - 60s`. If you wait for expiry, you'll get 401s on in-flight requests.
## Sign out
`POST /auth/v1/logout` (with the user's access token) invalidates the refresh token server-side and emits a session-revocation event. Always clear client-side storage too. Server-side invalidation alone won't log the user out if the access token is still in localStorage.
```python Python theme={null}
requests.post(
f"{BASE_URL}/auth/v1/logout",
headers={
"apikey": ANON_KEY,
"Authorization": f"Bearer {access_token}",
},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/auth/v1/logout`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${accessToken}`,
},
});
localStorage.removeItem("powabase_access_token");
localStorage.removeItem("powabase_refresh_token");
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/auth/v1/logout' \
-H "apikey: " \
-H "Authorization: Bearer "
```
The `scope` query parameter can be `global` (default, revoke refresh tokens on all devices), `local` (just this session), or `others` (everywhere except this device).
## Storing tokens
A short detour, because this trips people up. Where you store the tokens depends on your environment:
* **Browser SPA:** `localStorage` is fine for most apps. The Anon Key is already public, the access token is short-lived, and the refresh token's single-use rotation limits damage from XSS-stolen tokens. If XSS is a serious concern, use an httpOnly cookie set by your backend proxy.
* **Server-side rendering (Next.js, Remix, etc.):** use httpOnly cookies set by the server. The client never sees the tokens; your server attaches them on outbound API calls.
* **Mobile (iOS, Android):** the OS-native secure storage (Keychain on iOS, EncryptedSharedPreferences on Android). Don't put refresh tokens in regular preferences.
* **CLI / desktop apps:** write to a `~/.config/your-app/credentials.json` with 0600 perms.
In every case, treat the refresh token as the more sensitive credential. It lasts indefinitely until rotated, while the access token expires in an hour.
## Next steps
Google, GitHub, and 20 more OAuth providers with PKCE.
What's inside the JWTs and how rotation works under the hood.
Full /auth/v1/\* endpoint surface.
The policy patterns that gate what signed-in users can see.
# BaaS + AI cookbook
Source: https://docs.powabase.ai/guides/baas-ai-cookbook
Four worked patterns that pair the BaaS primitives (PostgREST, Auth, Storage, Realtime) with the AI surface (agents, KBs, workflows). The composability story Powabase is built around.
The point of Powabase is that the BaaS substrate and the AI primitives compose. You can build an app that signs users in (Auth), stores their files (Storage), indexes those files into a knowledge base (Sources + KB), runs an agent that searches the KB (Agents), and streams the answer back to the user, all on one platform, with one auth model, with RLS gating the right things.
This page is four worked patterns that string those primitives together. None of them is the only right way; treat them as starting points you'll adapt to your shape. For deeper dives on each primitive, see the per-area pages linked from the Cards at the end.
One caveat drives Recipe 2: **the platform does not forward end-user JWTs to agent tools.** See [JWT forwarding investigation](#why-recipe-2-is-shaped-this-way) below the recipes.
## Recipe 1: RLS-aware agent context via PostgREST on ai.\*
The pattern: you have an agent that needs to retrieve from a knowledge base, but you want each end user to see only their own sources within that KB. The naive approach (agent has unrestricted KB access; you filter results in your app) leaks data via the response.
The right approach: query `ai.chunks` directly via PostgREST under the user's JWT before invoking the agent, and pass the resulting context into the agent run as `context_items`.
**Step 1: RLS on `ai.indexed_sources` and `ai.chunks`** (assuming a `user_id` column on your `ai.sources` table tracks ownership):
```sql theme={null}
-- ai.sources already has RLS enabled by default; tighten the authenticated
-- read policy to only show user's own sources.
DROP POLICY IF EXISTS auth_read_sources ON ai.sources;
CREATE POLICY auth_read_own_sources ON ai.sources
FOR SELECT TO authenticated
USING (
-- Adapt to wherever your "who owns this source" lives — most apps
-- have a public.documents row with owner_id that joins to ai.sources.id
EXISTS (
SELECT 1 FROM public.documents
WHERE source_id = ai.sources.id
AND owner_id = auth.uid()
)
);
-- ai.chunks inherits the source's ownership.
DROP POLICY IF EXISTS auth_read_chunks ON ai.chunks;
CREATE POLICY auth_read_own_chunks ON ai.chunks
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM ai.indexed_sources ix
JOIN public.documents d ON d.source_id = ix.source_id
WHERE ix.id = ai.chunks.indexed_source_id
AND d.owner_id = auth.uid()
)
);
```
**Step 2: query for chunks from the browser**, using the user's access token:
```typescript theme={null}
async function retrieveContext(kbId: string, query: string, userToken: string) {
// PostgREST does the RLS-filtered hybrid search; we just need top-K chunks
// ordered by similarity. For real hybrid search, you'd typically POST to
// /api/knowledge-bases/{kbId}/search instead — but that runs with the
// service role and skips RLS. Hand-rolling against ai.chunks under the
// user's JWT gets per-user filtering for free.
const params = new URLSearchParams({
select: "id,text,score,source_id,meta",
knowledge_base_id: `eq.${kbId}`,
order: "score.desc",
limit: "10",
});
const res = await fetch(`${BASE_URL}/rest/v1/chunks?${params}`, {
headers: {
"Accept-Profile": "ai",
apikey: ANON_KEY,
Authorization: `Bearer ${userToken}`,
},
});
return res.json();
}
```
**Step 3: pass the retrieved chunks as agent context\_items.** The agent run endpoint accepts a `context_items` array that bypasses the agent's own retrieval:
```typescript theme={null}
async function askAgent(agentId: string, message: string, contextItems: any[], userToken: string) {
// The agent run endpoint runs under the service role internally — it does
// not check RLS on its own tools. Filtering happened above, when we
// retrieved chunks under the user's JWT.
const res = await fetch(`${BASE_URL}/api/agents/${agentId}/run`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${SERVICE_ROLE_KEY}`, // see Recipe 2 about why
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
context_items: contextItems.map((c) => ({
text: c.text,
source_id: c.source_id,
meta: c.meta,
})),
}),
});
return res.json();
}
```
The agent run no longer touches `ai.chunks` itself (because we pre-fed the context). The data path is: user JWT → PostgREST → RLS-filtered chunks → context\_items → agent. There's no point in the request where the agent has access to chunks the user shouldn't see.
**Trade-offs.** This pattern works for read-only retrieval. You're doing the RAG retrieval yourself, so you lose the platform's hybrid scoring, reranking, and the chunks→pages reflow that the typed search endpoint does for you. For full retrieval pipelines, the right answer is "use the typed search endpoint but call it from your backend, with your backend enforcing ownership before the call," at the cost of a service-role layer in the middle.
## Recipe 2: Build-your-own authenticated chatbot
A chat app where each user has their own agent (or shares an agent with others). The user sends a message, the agent runs, the response streams back to the user. RLS should ensure that user A's chat history isn't visible to user B.
**Important up-front:** the obvious approach, letting the browser call `/api/agents/{id}/run/stream` directly with the user's access token, looks like it works but is unsafe.
### Why the obvious approach is wrong
When you send a request to `/api/agents/{id}/run/stream` with `Authorization: Bearer `:
1. The user's JWT is signed with the same `JWT_SECRET` as the Service Role Key, just with `role: "authenticated"` instead of `role: "service_role"`.
2. `@require_auth` accepts both: the JWT signature validates and the request is authorized.
3. **But** the platform does not propagate the user's identity to the agent's tools. The agent's `database_query`/`database_write` builtin tools run under a superuser Postgres role (`supabase_admin`) inside the project-service worker. Whatever the agent decides to query, it gets back unfiltered data, including other users' rows.
So a user who hits `/run/stream` directly with their own JWT gets an answer, but the answer is computed with full project-wide DB access. If the agent has database tools, it can leak data from other users. **Don't expose `/api/agents/{id}/run/stream` to clients with their own JWTs.**
### The safe pattern
Run the agent run from a trusted backend with the Service Role key. Inject user-scoped context yourself, either via `context_items` (Recipe 1's pattern) or by constructing a Custom HTTP Tool that calls an auth-checked route on your own backend.
```typescript theme={null}
// Backend route. Express-style for illustration.
app.post("/api/chat", requireUserAuth, async (req, res) => {
const { userId, userJwt } = req.user; // from your own auth middleware
const { message } = req.body;
// Look up the user's agent — or use a shared agent ID for the whole app.
const agentId = await getAgentForUser(userId);
// Persist the chat message to your own table for history. RLS-protected
// public.chat_messages with owner_id = userId enforces per-user history.
const sessionId = await ensureSession(userId);
// Stream the agent run from the backend to the client. The platform's SSE
// stream is preserved by streaming the response body through your handler.
const agentRes = await fetch(`${POWABASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers: {
apikey: SERVICE_ROLE_KEY,
Authorization: `Bearer ${SERVICE_ROLE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
session_id: sessionId,
// If you need the agent to access user-specific data, pre-fetch and
// inject as context_items here. See Recipe 1.
}),
});
// Forward the SSE stream to the client.
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
agentRes.body.pipe(res);
});
```
The client never sees the Service Role key; only your backend does. Your backend enforces who can call `/api/chat` (via `requireUserAuth`), and the agent run happens with full database access but only acts on context you injected.
### Adding a Custom Tool that's user-aware
For agents that need to query user-specific data dynamically (e.g., "search through my saved articles"), register a Custom HTTP Tool that points at your own backend:
```bash theme={null}
POST /api/tools
{
"name": "search_user_articles",
"description": "Searches the user's saved articles for relevant content.",
"type": "http",
"input_schema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"session_token": { "type": "string" }
},
"required": ["query", "session_token"]
},
"config": {
"endpoint": "https://your-app.example.com/internal/tools/search-articles",
"method": "POST"
}
}
```
Then in your backend's `/internal/tools/search-articles` handler:
```typescript theme={null}
app.post("/internal/tools/search-articles", async (req, res) => {
const { query, session_token } = req.body;
// Look up the user from your opaque session_token — NOT from any header
// because the platform doesn't forward auth to custom tools.
const userId = await sessionStore.lookup(session_token);
if (!userId) return res.status(401).end();
// Now run the query against your DB with the user's identity applied.
const results = await db.searchArticles(userId, query);
res.json({ results });
});
```
The pattern: pass an opaque `session_token` through the tool's arguments at call time. Your backend resolves it to the user identity and gates the query. The agent doesn't know who the user is; your backend does.
The system prompt for the agent includes "always call `search_user_articles` with `session_token = `." You inject the current user's token into the system prompt at agent-run-time, server-side. From the agent's perspective, the token is opaque data it passes through.
This pattern is more work than "the platform forwards my JWT" would be. It's the safe alternative given current platform behavior.
## Recipe 3: Storage vs ai.sources
You have a multi-format app: users upload PDFs that need to be indexed for RAG, plus they upload images for display. Two distinct flows.
**For files that need to enter the RAG pipeline (PDFs, docs, anything you want chunked + embedded):**
* Upload via `POST /api/sources/upload` (the typed Sources endpoint).
* The platform creates a row in `ai.sources`, stores the file in a platform-managed Storage bucket, dispatches the extraction Celery task, and returns the source id.
* Add the source to a KB via `POST /api/knowledge-bases/{id}/sources` to trigger indexing.
**For files that are user content displayed back to the user (avatars, gallery images, attachments):**
* Upload via `POST /storage/v1/object/{bucket}/{path}` (the typed Storage endpoint).
* The file lands in `storage.objects` under your own bucket. Your application code reads/displays it.
The two flows use different buckets, different tables (`ai.sources` vs `storage.objects`), and have different RLS postures. **Don't try to share buckets between them.** The platform assumes ownership of its Sources bucket layout and will overwrite paths.
For files that need to be both (e.g., a PDF the user uploaded that they should be able to download AND that's indexed for RAG): upload twice, once through each path. They're independent: one row in `storage.objects` (for download) and one row in `ai.sources` (for indexing). Or pick one path, upload to `ai.sources`, and use the platform's pre-signed download URL for the user-facing download.
A worked end-to-end flow for an "upload a PDF, then chat with it" pattern:
```typescript theme={null}
async function uploadAndChat(file: File, userToken: string) {
// Step 1: Upload to Sources (will be indexed). The user's access token here
// is for record-keeping; the actual upload runs server-side via your backend
// if you want auth scoping on what the user can upload.
const sourceRes = await fetch(`${BASE_URL}/api/sources/upload`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${SERVICE_ROLE_KEY}`, // Service role — see Recipe 2
},
body: (() => {
const fd = new FormData();
fd.append("file", file);
return fd;
})(),
});
const { id: sourceId } = await sourceRes.json();
// Step 2: Record ownership (so RLS can scope future reads)
await postgrest("documents", { source_id: sourceId, owner_id: userId });
// Step 3: Poll for extraction completion (see Sources Reference for shape)
await pollUntilExtracted(sourceId);
// Step 4: Add to a user-specific KB
const kbId = await getUserKb(userId);
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/sources`, {
method: "POST",
headers: { apikey: ANON_KEY, Authorization: `Bearer ${SERVICE_ROLE_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ source_id: sourceId }),
});
// Step 5: Now an agent linked to that KB can answer questions about the PDF.
return runChat(kbId, userToken);
}
```
`public.documents` is your own table for "who owns which source." `ai.sources` is the platform's table; `public.documents.source_id` references it for the ownership graph.
## Recipe 4: Realtime + agent runs
For showing live progress during agent runs: the agent SSE stream is one event source, but if you want UI updates to reach other devices too (the user's phone watching while their laptop runs the chat), Realtime is the right second channel.
Two patterns:
**Pattern 4a: Mirror the SSE stream to a Realtime channel.** Your backend receives the SSE stream from `/api/agents/{id}/run/stream`, persists each event to a row in a `public.chat_messages` table, AND re-broadcasts each event to a per-user Realtime channel. Clients subscribe to the channel for live updates.
```typescript theme={null}
async function streamAndBroadcast(agentId: string, userId: string, message: string) {
// Open the agent SSE stream
const agentRes = await fetch(`${POWABASE_URL}/api/agents/${agentId}/run/stream`, { ... });
const reader = agentRes.body!.getReader();
// The Realtime topic this user is subscribed to
const topic = `chat:${userId}`;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
for (const line of text.split("\n")) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
// Persist for chat history
if (event.event === "chunk" || event.event === "complete") {
await db.chatMessages.insert({ user_id: userId, payload: event });
}
// Broadcast for live UIs
await broadcast({
topic,
event: event.event,
payload: event,
});
}
}
}
```
**Pattern 4b: Use a database trigger.** Insert each agent event into `public.chat_messages`; a trigger function calls `realtime.send()` to broadcast it. The application code doesn't need to call Realtime explicitly; the database side-effect drives the broadcast.
```sql theme={null}
CREATE OR REPLACE FUNCTION broadcast_chat_message()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.send(
payload => jsonb_build_object('message_id', NEW.id, 'event', NEW.payload),
event => 'chat_event',
topic => 'chat:' || NEW.user_id::text,
private => true
);
RETURN NEW;
END;
$$;
CREATE TRIGGER chat_realtime
AFTER INSERT ON public.chat_messages
FOR EACH ROW EXECUTE FUNCTION broadcast_chat_message();
```
Pair this with the matching RLS on `realtime.messages` (so only the user themselves can subscribe to their channel) and you have device-syncing chat, gated by the same auth model as the rest of the app. See [Realtime model](/concepts/realtime) for the function reference.
## Why Recipe 2 is shaped this way
A platform investigation while drafting the audit found that the agent surface does **not** forward end-user JWTs to its tools. Concretely:
* `@require_auth` accepts user JWTs and sets `g.user_id` from the `sub` claim
* `g.user_id` is consumed only by session-ownership checks; never propagated to tool dispatch
* The `database_query`/`database_write` builtin tools execute SQL via the project service's SQLAlchemy session, configured from `DATABASE_URL=postgres://supabase_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}` (a superuser connection) with no `SET ROLE` and no `set_config('request.jwt.claims', ...)`
* Custom HTTP tools forward only the `config.headers` set at tool-create time — no request-time auth injection
The implication: anyone reaching `/api/agents/{id}/run/stream` directly with a user JWT gets full project-wide database access from any tool the agent calls. This is the footgun Recipe 2's "safe pattern" works around. If and when the platform adds JWT forwarding (some kind of `act_as_user` parameter or automatic propagation), the safe pattern gets a lot simpler.
For now: only call agent endpoints from trusted backends, with the Service Role key, with user-scoped context pre-injected.
## Next steps
PostgREST patterns on ai.\* that complement these higher-level cookbooks.
The lower-level RLS patterns these recipes build on.
What every agent run and workflow execution costs in credits.
Detailed Realtime patterns the recipe-4 broadcast is one application of.
# Build an Agent
Source: https://docs.powabase.ai/guides/build-agent
Create an AI agent, assign tools and knowledge bases, then chat with it via streaming. Agents run a ReAct loop: they reason, call tools, and respond.
Agents are the conversational interface to your AI features. This guide creates an agent, gives it a tool and a knowledge base, then runs a streaming multi-turn conversation against it.
**Prerequisites:**
* Authentication configured (see Authentication guide)
* Optional: A knowledge base for RAG (see Create a Knowledge Base guide)
Define the agent with a name, LLM model, and system prompt that describes its behavior.
**Endpoint:** `POST /api/agents`
`model` is a LiteLLM model ID. Bare IDs (`gpt-4o`, `claude-sonnet-4-6`) route to OpenAI/Anthropic; other providers are prefixed (`gemini/...`, `openrouter/...`). The field is not limited to the Studio model picker, but a provider you haven't [registered a key](/api-reference/ai-provider-keys) for (and that isn't AI-on-us) returns `402 provider_key_decrypt_failed`. [Bring your own LLM](/guides/byollm) covers the formats and an OpenRouter/DeepSeek example.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents",
headers=headers,
json={
"name": "Support Bot",
"model": "gpt-4o",
"system_prompt": "You are a helpful support assistant. Answer questions using the knowledge base when available.",
"settings": {"temperature": 0.7},
},
)
agent = response.json()
agent_id = agent["id"]
print(f"Agent created: {agent_id}")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Support Bot",
model: "gpt-4o",
system_prompt: "You are a helpful support assistant. Answer questions using the knowledge base when available.",
settings: { temperature: 0.7 },
}),
});
const agent = await response.json();
console.log("Agent created:", agent.id);
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Support Bot",
"model": "gpt-4o",
"system_prompt": "You are a helpful support assistant.",
"settings": {"temperature": 0.7}
}'
```
Enable the agent to use builtin tools like database\_query, http\_request, or code\_execute.
**Endpoint:** `POST /api/agents/{id}/tools`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/tools",
headers=headers,
json={"tool_name": "database_query"},
)
print(response.json())
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/tools`, {
method: "POST",
headers,
body: JSON.stringify({ tool_name: "database_query" }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{agent_id}/tools' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"tool_name": "database_query"}'
```
Assign a knowledge base and the agent automatically gets a search tool for it. During a conversation it can search the KB to ground its responses.
Don't want a permanent attachment? Pass `runtime_knowledge_bases` on `POST /api/agents/{id}/run/stream` instead — the agent gets the same `knowledge_search` tool over the KBs you name, for that one request only. See [Runtime knowledge base references](/api-reference/agents#runtime-knowledge-base-references).
**Endpoint:** `POST /api/agents/{id}/knowledge-bases`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/knowledge-bases",
headers=headers,
json={"knowledge_base_id": kb_id},
)
print(response.json())
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/agents/${agentId}/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({ knowledge_base_id: kbId }),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{agent_id}/knowledge-bases' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"knowledge_base_id": "{kb_id}"}'
```
Send a message and receive a Server-Sent Events (SSE) stream. Events include tool calls, tool results, and the final response.
**Endpoint:** `POST /api/agents/{id}/run/stream`
SSE events: start, chunk, step\_started, tool\_call, tool\_result, step\_completed, approval\_requested, complete, error.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json={"message": "What does the product documentation say about getting started?"},
stream=True,
)
session_id = None
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if text.startswith("data: "):
import json
event = json.loads(text[6:])
if event["event"] == "start":
session_id = event["session_id"]
elif event["event"] == "chunk":
print(event["content"], end="")
elif event["event"] == "tool_call":
print(f"\n[Tool: {event['tool_name']}]")
elif event["event"] == "complete":
print(f"\n\nDone. Session: {session_id}")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify({
message: "What does the product documentation say about getting started?",
}),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let sessionId: string | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
for (const line of text.split("\n")) {
if (line.startsWith("data: ")) {
const event = JSON.parse(line.slice(6));
if (event.event === "start") sessionId = event.session_id;
if (event.event === "chunk") process.stdout.write(event.content);
if (event.event === "tool_call") console.log(`\n[Tool: ${event.tool_name}]`);
if (event.event === "complete") console.log(`\nDone. Session: ${sessionId}`);
}
}
}
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "What does the product documentation say about getting started?"}'
```
Pass the session\_id from the previous run to continue the multi-turn conversation. The agent retains full message history within the session.
**Endpoint:** `POST /api/agents/{id}/run/stream`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json={
"message": "Can you summarize that in bullet points?",
"session_id": session_id,
},
stream=True,
)
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if text.startswith("data: "):
event = json.loads(text[6:])
if event["event"] == "chunk":
print(event["content"], end="")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify({
message: "Can you summarize that in bullet points?",
session_id: sessionId,
}),
});
// ... parse SSE stream same as above
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "Can you summarize that in bullet points?", "session_id": "{session_id}"}'
```
## What's Next
Deep dive into SSE event handling.
Add MCP servers, hooks, and approval flows.
Understand the ReAct loop and tool system.
# Bring your own LLM
Source: https://docs.powabase.ai/guides/byollm
Point agents, copilots, and indexing jobs at any LiteLLM-supported model (including OpenRouter models like DeepSeek) using the correct provider key and model-string format.
Powabase resolves every model through [LiteLLM](https://docs.litellm.ai/), so the `model` string you pass to an agent, an orchestration, or an indexing job is a **LiteLLM model ID**. Two things have to be right: the registered provider key, and the model string's format for that provider.
**Prerequisites:**
* Authentication configured (see the [Connect & authenticate](/guides/auth-connection) guide)
* An API key for the upstream provider you want to use (OpenAI, Anthropic, Google, or OpenRouter)
## Model-string format
The prefix tells LiteLLM which provider to route to. Bare IDs route to first-party OpenAI/Anthropic; everything else is prefixed.
| Provider | Format | Example |
| --------------- | -------------------------- | -------------------------------------- |
| OpenAI | bare ID | `gpt-4o`, `gpt-5.4-mini`, `o4-mini` |
| Anthropic | bare ID | `claude-sonnet-4-6` |
| Google (Gemini) | `gemini/` | `gemini/gemini-2.5-pro` |
| OpenRouter | `openrouter//` | `openrouter/qwen/qwen3-235b-a22b-2507` |
**OpenRouter slugs must match LiteLLM's cost-map keys, not OpenRouter's own slugs.** LiteLLM's `openrouter/...` identifiers occasionally differ from the slugs shown on OpenRouter's website or `/api/v1/models`. When a model errors with a routing or cost-lookup failure, check the slug first: confirm it exists as a key in LiteLLM's [model cost map](https://github.com/BerriAI/litellm/blob/main/litellm/model_prices_and_context_window_backup.json) (search for `openrouter/`).
The `model` field on an **agent** passes straight through to LiteLLM and is **not** restricted to the model picker shown in Studio. The curated dropdown only gates the Studio UI and the `AGENT_DEFAULT_MODEL` setting; via the API you can set any LiteLLM-resolvable model string. It must support **function calling** if the agent has tools or a knowledge base, since a non-tool model fails on its first tool call.
## Which keys do you need?
Four providers accept a bring-your-own-key (BYOK) credential: `openai`, `anthropic`, `google`, `openrouter`. Register one with the [AI Provider Keys API](/api-reference/ai-provider-keys), then point your model string at it.
If a provider is also **AI-on-us** on your pod (the platform has a server-side key for it), you can skip BYOK and pay in credits instead. Check which providers are covered:
```bash cURL theme={null}
curl '{BASE_URL}/api/ai-provider-keys/platform_supported' -H "apikey: {API_KEY}" -H "Authorization: Bearer {API_KEY}"
# {"providers": ["openai", "anthropic"]}
```
OpenRouter is rarely AI-on-us, so for OpenRouter models you almost always need your own key. Without a usable key, agent runs fail with `402 provider_key_decrypt_failed`. The [billing model](/concepts/billing-model#byok-provider-keys-and-ai-on-us) covers how BYOK and credits interact.
## Worked example: DeepSeek via OpenRouter
```python Python theme={null}
requests.post(
f"{BASE_URL}/api/ai-provider-keys",
headers=headers,
json={"provider": "openrouter", "api_key": "sk-or-..."},
)
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/ai-provider-keys' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"provider": "openrouter", "api_key": "sk-or-..."}'
```
The platform validates the key against the provider before storing it. A hard rejection (bad credential) returns `400` and stores nothing.
Use the `openrouter//` format. DeepSeek's OpenRouter org is `deepseek`:
```python Python theme={null}
agent = requests.post(
f"{BASE_URL}/api/agents",
headers=headers,
json={
"name": "DeepSeek Agent",
"model": "openrouter/deepseek/deepseek-chat",
"system_prompt": "You are a helpful assistant.",
},
).json()
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "DeepSeek Agent", "model": "openrouter/deepseek/deepseek-chat"}'
```
`openrouter/deepseek/deepseek-chat` follows the documented format, but confirm the exact slug against LiteLLM's cost map (see the warning above). DeepSeek publishes several models (chat vs. reasoner, for instance), and the LiteLLM key is what the platform routes on. If you give the agent tools, pick a DeepSeek model that supports function calling.
Run the agent as usual. The platform decrypts your OpenRouter key at call time and routes the request through LiteLLM.
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/agents/{agent_id}/run' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "Hello!"}'
```
A `402 provider_key_decrypt_failed` means the OpenRouter key is missing or unreadable; re-register it in step 1.
## Related
Store, validate, rotate, and inspect per-project provider credentials.
Create an agent, assign tools and a knowledge base, then stream a conversation.
How AI-on-us credits and BYOK keys interact, and the 402/503 error shapes.
Defaults like `AGENT_DEFAULT_MODEL` and the curated model choices.
# Connection Pooling
Source: https://docs.powabase.ai/guides/connection-pooling
All external Postgres connections in Powabase go through PgBouncer in transaction mode. What that buys you, what it breaks, and how to write code that works against it.
The Database URL you copy from the Connect modal is a PgBouncer pooler URL, not a direct connection to your project's Postgres. PgBouncer multiplexes many client connections onto a smaller pool of Postgres connections, which is how serverless and short-lived processes can hit Postgres without overwhelming it.
There's one mode (transaction), and its constraints bite people coming from direct Postgres. This guide walks through the URL format, what the constraints actually are, and the workarounds.
If you need direct (non-pooled) access for migrations or long-running admin scripts, see the bottom of the page.
## The URL format
The Database URL handed out by the Connect modal looks like this:
```
postgresql://{ref}:{password}@db.p.powabase.ai:5432/{ref}
```
Three things to notice:
* **Username and database are both your project ref**, not `postgres`. Coming from Supabase or a self-hosted Postgres, the muscle memory is `postgres:postgres@host:5432/postgres`. Powabase uses the ref consistently, because PgBouncer routes by the database name to figure out which project's Postgres to forward to.
* **Port 5432** (not 6543). PgBouncer listens on the standard Postgres port; there's no separate session-mode endpoint on 5544/6543/etc.
* **Pooler hostname** `db.p.powabase.ai`. This is a shared LoadBalancer pointing at the PgBouncer pods in the `shared-services` namespace. The per-project Postgres lives elsewhere and isn't directly reachable.
## What "transaction mode" means
PgBouncer in transaction mode assigns a server connection to a client at `BEGIN`, holds it for the duration of that transaction, and returns it to the pool at `COMMIT`/`ROLLBACK`. Two clients that issue interleaved statements outside a transaction will likely land on different server connections; there's no guarantee of "stickiness" across statements.
This is what lets a small pool (20 connections per project by default, 200 cluster-wide) serve thousands of client connections. The cost: it breaks any feature that needs a server connection to persist across multiple statements.
### What works
* All single-statement queries.
* Any sequence of statements wrapped in an explicit `BEGIN`/`COMMIT`.
* `BEGIN`/`SAVEPOINT`/`ROLLBACK` within a transaction.
* Standard CRUD.
* Read replicas if you've set them up (PgBouncer transparently routes by SQL).
### What breaks
* **`LISTEN`/`NOTIFY`.** Notifications are delivered to the server connection that issued `LISTEN`; in transaction mode, you'll get a different server connection on the next statement and the listener is gone. **Use Realtime instead** (`/realtime/v1/*`) for change notifications.
* **Prepared statements via the extended query protocol.** PostgreSQL's prepared-statement cache lives on the server connection; if your driver prepares once and reuses across transactions, the prepared statement isn't on the next server connection and you get `prepared statement "..." does not exist`. **Workaround:** disable prepared statements in your driver config (most have a flag).
* **Session-level `SET`.** `SET statement_timeout = '5s'` outside a transaction "sticks" to one server connection, so your next statement on a different connection sees the default. **Workaround:** use `SET LOCAL` inside a transaction, or pass settings via the connection string (`?options=-c%20statement_timeout%3D5000`).
* **Advisory locks across statements.** `pg_advisory_lock(...)` outside a transaction locks one server connection, then is released or moot when you switch. **Workaround:** use transaction-scoped advisory locks (`pg_advisory_xact_lock`) which are released on `COMMIT`.
* **Temporary tables across statements.** Same reason. Use them inside a transaction.
## Per-driver configuration
Most drivers have a setting to disable prepared statements or use the simple query protocol. The relevant flag:
| Language / driver | What to set |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node.js `pg` | `Client({ connectionString, statement_timeout: undefined })` is fine; for prepared statements use `client.unsafe(...)` from `postgres` or pass `?statement_cache_mode=safe` on `node-postgres` |
| Python `psycopg` 3 | `psycopg.connect("...", prepare_threshold=None)` disables auto-prepare. `prepare_threshold=0` always prepares, breaks under pooling. |
| Python `asyncpg` | `asyncpg.connect("...", statement_cache_size=0)` |
| Python `psycopg2` | Doesn't auto-prepare; nothing to do unless your code calls `cur.execute(..., prepared=True)`. |
| SQLAlchemy | `create_engine("...", connect_args={"prepare_threshold": None})` for psycopg3; or `engine = create_engine("...", poolclass=NullPool)` if you also want to disable SQLAlchemy's own pooling (PgBouncer is already doing it). |
| Prisma | Append `?pgbouncer=true&connection_limit=1` to the URL. The `pgbouncer=true` flag turns off prepared statements; `connection_limit=1` prevents Prisma from opening more connections than your pool can serve. |
| Drizzle / `postgres.js` | Append `?prepare=false` to the URL, or pass `{ prepare: false }` in the client options. |
| Go `pgx` v5 | `config.DefaultQueryExecMode = pgx.QueryExecModeExec` on `*pgxpool.Config`. |
| Go `database/sql` + `lib/pq` | Doesn't auto-prepare; nothing to do. |
| JDBC | Append `?prepareThreshold=0&binaryTransfer=false` to the JDBC URL. |
| .NET `Npgsql` | Append `;Max Auto Prepare=0;No Reset On Close=true` to the connection string. |
| PHP `pg_connect` | Doesn't auto-prepare; nothing to do unless you call `pg_prepare()`. |
If you're using a serverless runtime (Lambda, Vercel Functions, Cloudflare Workers) on top of these drivers, the right pattern is: **create one client per request, run your transactions, close**. Letting clients live across invocations leaks server connections, and PgBouncer eventually starts refusing new ones (`max_client_conn = 200` cluster-wide).
## Sizing your client-side pool
You have **20 server connections per project**. That's the ceiling. Above it, PgBouncer queues your requests until a connection frees up; queueing past a few hundred ms manifests as latency spikes.
For application servers, set your client-side pool to a fraction of this (typically 10-15) to leave headroom for migrations, cron jobs, and the platform's own backend. Setting the client pool to 20 from a single replica fully consumes the project's allocation; running two replicas each with 20 means one's queries get queued behind the other's.
A safe baseline for a single replica: **client pool size 10**, **client connection timeout 5s**, **statement timeout 30s** (matching PostgREST's authenticator role). Tune up if you see your own application's request rate getting throttled before PgBouncer's queue fills.
## When transaction-mode doesn't work
Some workloads genuinely need session state: long migrations with `SET LOCAL`, schema changes with advisory locks, anything where multiple statements share a server connection without being wrapped in a single transaction. You have two options:
1. **Wrap everything in a single transaction.** Most use cases are amenable: open a transaction at the start of the script, do all the work, commit at the end. This works as long as the transaction completes before any statement-level timeout (PostgREST authenticator is 30s; the connection itself has no timeout).
2. **Hit Postgres directly, bypassing PgBouncer.** Today this is only available from inside the project's namespace (e.g., a Kubernetes Job running inside `project-{ref}` can connect to `postgres.project-{ref}.svc.cluster.local:5432`). There's no public direct-connection URL. For one-off operations from outside the cluster, contact support.
## Next steps
Where the pooler URL comes from in the Connect modal.
PostgREST vs typed API vs direct Postgres: when each is right.
The schema-level patterns most apps use instead of writing raw SQL.
The auth posture you'll want set up before exposing direct Postgres patterns to clients.
# Create a Knowledge Base
Source: https://docs.powabase.ai/guides/create-knowledge-base
Index your documents for semantic search and retrieval-augmented generation. Knowledge bases chunk, embed, and store your content for fast vector similarity search.
Knowledge bases let your AI agents search and retrieve relevant information from your documents. This guide walks through creating a KB, adding a source, waiting for indexing, and testing semantic search.
**Prerequisites:**
* A completed source (see Upload a Document guide)
Choose a name and indexing strategy. The default strategy works well for most documents.
**Endpoint:** `POST /api/knowledge-bases`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/knowledge-bases",
headers=headers,
json={
"name": "Product Docs",
"description": "Product documentation and guides",
},
)
kb = response.json()
kb_id = kb["id"]
print(f"KB created: {kb_id}")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Product Docs",
description: "Product documentation and guides",
}),
});
const kb = await response.json();
console.log("KB created:", kb.id);
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "Product Docs", "description": "Product documentation and guides"}'
```
**Response:**
```json theme={null}
{
"id": "kb-uuid",
"name": "Product Docs",
"description": "Product documentation and guides",
"indexing_config": {
"strategy": "chunk_embed",
"...": "strategy defaults"
},
"retrieval_config": {
"...": "strategy defaults"
}
}
```
If you pass `indexing_config` or `retrieval_config` in the request, your values are merged over the strategy defaults. Omit either field to accept the defaults for `strategy` (default `chunk_embed`).
The retrieval features ([reranking](/concepts/knowledge-bases-indexing#reranking), [query enrichment](/concepts/knowledge-bases-indexing#query-enrichment), and [multimodal retrieval](/concepts/knowledge-bases-indexing#multimodal-retrieval) via `context_mode: "image"`) all live inside `retrieval_config`. Turn them on here at creation, or add them later with `PATCH /api/knowledge-bases/{id}` (no reindex required).
Link an uploaded source (from the previous guide) to trigger indexing. The source's extracted content is chunked, embedded, and stored.
**Endpoint:** `POST /api/knowledge-bases/{id}/sources`
Indexing runs asynchronously. For large documents this can take 30 seconds or more.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/knowledge-bases/{kb_id}/sources",
headers=headers,
json={"source_id": source_id},
)
print(response.json())
```
```typescript TypeScript theme={null}
const response = await fetch(
`${BASE_URL}/api/knowledge-bases/${kbId}/sources`,
{
method: "POST",
headers,
body: JSON.stringify({ source_id: sourceId }),
},
);
console.log(await response.json());
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{kb_id}/sources' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"source_id": "{source_id}"}'
```
Fetch the knowledge base to see the status of each indexed source. Wait until all sources show 'indexed'.
**Endpoint:** `GET /api/knowledge-bases/{id}`
```python Python theme={null}
response = requests.get(
f"{BASE_URL}/api/knowledge-bases/{kb_id}",
headers=headers,
)
kb = response.json()
for src in kb.get("indexed_sources", []):
print(f"Source {src['source_id']}: {src['index_status']}")
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}`, { headers });
const kb = await res.json();
kb.indexed_sources?.forEach((s: any) =>
console.log(`Source ${s.source_id}: ${s.index_status}`)
);
```
```bash cURL theme={null}
curl '{BASE_URL}/api/knowledge-bases/{kb_id}' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
```
Run a semantic search query against the knowledge base to verify indexing worked.
**Endpoint:** `POST /api/knowledge-bases/{id}/search`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/knowledge-bases/{kb_id}/search",
headers=headers,
json={"query": "How do I get started?", "top_k": 5},
)
results = response.json()
for r in results.get("results", []):
print(f"Score: {r['score']:.3f} — {r['text'][:80]}...")
```
```typescript TypeScript theme={null}
const res = await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/search`, {
method: "POST",
headers,
body: JSON.stringify({ query: "How do I get started?", top_k: 5 }),
});
const { results } = await res.json();
results.forEach((r: any) => console.log(`Score: ${r.score} — ${r.text.slice(0, 80)}...`));
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/knowledge-bases/{kb_id}/search' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"query": "How do I get started?", "top_k": 5}'
```
## What's Next
Create an agent that uses your knowledge base.
Deep dive into chunking and embeddings.
Full endpoint documentation.
# Database webhooks
Source: https://docs.powabase.ai/guides/db-webhooks
Fire HTTP requests when rows change, using the supabase_functions.http_request() trigger function plus pg_net. Distinct from the agentic /api/webhooks workflow triggers.
You can fire an HTTP request from inside Postgres when a row in a table changes. The mechanism is a trigger function (`supabase_functions.http_request()`) plus `pg_net`, the async HTTP extension, both preloaded in every Powabase project. Reach for it when you want to "tell our internal API every time a user signs up," "post to Slack on order creation," or "invalidate a cache when this row updates."
**This is NOT the same as the agentic webhooks at `/api/webhooks`.** Those are the inbound side: external systems triggering workflows. Database webhooks are the outbound side: Postgres rows changing and Postgres calling out to some HTTP endpoint. Don't confuse them.
For the agentic surface, see [Webhooks reference](/api-reference/webhooks). For pg\_net specifics, see [Extensions](/api-reference/extensions).
## How it works
The platform installs `pg_net` in the `extensions` schema and a helper trigger function `supabase_functions.http_request()` that wraps `pg_net.http_post`. You attach the function as a trigger to your table:
```sql theme={null}
CREATE TRIGGER notify_on_order_insert
AFTER INSERT ON public.orders
FOR EACH ROW
EXECUTE FUNCTION supabase_functions.http_request(
'https://your-internal-api.example.com/webhooks/orders',
'POST',
'{"Content-Type":"application/json","Authorization":"Bearer your-secret"}',
'{}',
'5000'
);
```
Arguments to `http_request`:
1. **URL**: where to POST.
2. **Method**: typically `POST`; `PUT`/`PATCH`/`DELETE` also work.
3. **Headers** (JSONB): your auth, content-type, custom headers.
4. **Params** (JSONB): query string params, if any.
5. **Timeout** (ms): how long pg\_net waits before giving up.
The request body is the **`NEW` row** for INSERT/UPDATE triggers, serialized as JSON. For DELETE triggers, it's the `OLD` row. The function doesn't let you customize the body; see below for that.
## Customizing the body
The default body is the bare row. For richer payloads, write your own trigger function that wraps `pg_net.http_post`:
```sql theme={null}
CREATE OR REPLACE FUNCTION public.notify_order_webhook()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, extensions
AS $$
DECLARE
payload jsonb;
request_id bigint;
BEGIN
payload := jsonb_build_object(
'event', TG_OP,
'table', TG_TABLE_NAME,
'old', row_to_json(OLD),
'new', row_to_json(NEW),
'timestamp', now()
);
SELECT net.http_post(
url := 'https://your-internal-api.example.com/webhooks/orders',
body := payload,
headers := '{"Content-Type":"application/json","Authorization":"Bearer your-secret"}'::jsonb,
timeout_milliseconds := 5000
) INTO request_id;
-- request_id can be used to look up the response in net._http_response
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER orders_webhook
AFTER INSERT OR UPDATE OR DELETE ON public.orders
FOR EACH ROW EXECUTE FUNCTION public.notify_order_webhook();
```
`pg_net.http_post` returns a `request_id`. The actual HTTP response (or error) lands later in the `net._http_response` table, asynchronously.
## Async semantics
`pg_net` is **fire-and-forget from the trigger's perspective**. The HTTP request runs in a background worker; the trigger function returns immediately after queuing it. Two implications:
* **The trigger doesn't block on the HTTP response.** Your INSERT commits as soon as the trigger queues the request. The HTTP call can fail without rolling back the INSERT.
* **You can't get the response synchronously.** If your trigger needs to know whether the call succeeded (for retry logic, for a transactional outbox pattern), you have to poll `net._http_response`.
For side-effect use cases like Slack notifications or cache invalidation, the async model is what you want. When the INSERT shouldn't succeed unless the webhook went through, use a transactional outbox pattern instead: insert into an outbox table inside the same transaction, then have a separate worker (or workflow) drain the outbox and call the webhook.
## Querying responses
To see how recent webhook calls went:
```sql theme={null}
SELECT id, status_code, error_msg, completed
FROM net._http_response
ORDER BY id DESC
LIMIT 20;
```
`status_code` is the HTTP response code (200, 4xx, 5xx). `error_msg` is populated on timeouts and connection errors.
The `net._http_response` table accumulates rows over time and isn't auto-pruned. For high-volume webhook senders, run a periodic cleanup:
```sql theme={null}
DELETE FROM net._http_response WHERE created < now() - interval '7 days';
```
Or set up a maintenance job via a workflow that runs nightly.
## Retries
`pg_net` does not retry failed requests. If your endpoint is down when the trigger fires, the request is lost.
Three retry patterns, depending on what you need:
**Option 1: Application-level retry on the receiving end.** The webhook arrives once; if the receiver wants idempotency, it dedupes by an event id you include in the payload.
**Option 2: Outbox table + scheduled retry.** Insert "I want to send X" rows into a `public.webhook_outbox` table inside the same transaction as the INSERT that triggers it. A scheduled workflow drains the outbox, calls the webhook, and marks rows as sent (or failed-retry).
**Option 3: Use the agentic `/api/webhooks` surface in the other direction.** Have your trigger call a deployed workflow URL; the workflow handles retries and observability inside Powabase. This trades async-HTTP simplicity for workflow visibility.
Most webhooks should be retryable on the receiver side (option 1). Reserve options 2/3 for cases where you need delivery guarantees.
## Common patterns
### Slack notification on row insert
```sql theme={null}
CREATE TRIGGER slack_new_order
AFTER INSERT ON public.orders
FOR EACH ROW
EXECUTE FUNCTION supabase_functions.http_request(
'https://hooks.slack.com/services/T00/B00/XXX',
'POST',
'{"Content-Type":"application/json"}',
'{}',
'3000'
);
```
The body is the new `orders` row serialized as JSON. Slack's webhook format expects `{ "text": "..." }`, so the raw row won't format nicely. Use a custom function (see "Customizing the body" above) to build a Slack-shaped payload.
### Cache invalidation on update
```sql theme={null}
CREATE OR REPLACE FUNCTION public.invalidate_user_cache()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM net.http_post(
url := 'https://your-app.example.com/internal/cache/invalidate',
body := jsonb_build_object('user_id', NEW.id),
headers := '{"X-Internal-Secret":"shared-secret"}'::jsonb
);
RETURN NEW;
END;
$$;
CREATE TRIGGER users_cache_invalidate
AFTER UPDATE ON public.users
FOR EACH ROW EXECUTE FUNCTION public.invalidate_user_cache();
```
### Audit log to S3
To ship every row change off-platform for auditing, point the webhook at an HTTPS endpoint that writes to S3: a Lambda, a small webhook service, or your own ingestion pipeline. Because pg\_net is async, your INSERT doesn't wait, and the audit writes happen on the side.
## DB webhooks vs Realtime postgres\_changes
Both fire on row changes. The difference:
| | DB webhook | Realtime postgres\_changes |
| ----------- | ------------------------------------------------- | ------------------------------------------- |
| Transport | HTTP POST | WebSocket |
| Receiver | Any HTTP endpoint | Connected clients |
| Persistence | None (`pg_net` queues, but no delivery guarantee) | None (WS subscribers see live changes only) |
| Retries | None built-in | None |
| Use case | Backend-to-backend integration | Client UI updates |
If your receivers are clients (browsers, mobile apps), use Realtime. If they're backend services / SaaS integrations, use DB webhooks.
## Next steps
The inbound side: external systems triggering workflows via /api/webhooks. A different surface, often confused with this one.
The browser-friendly alternative for change notifications.
pg\_net and what else lives in the extensions schema.
For installing triggers via psql or migrations.
# Direct Postgres patterns
Source: https://docs.powabase.ai/guides/direct-postgres
Connecting to your project's Postgres directly via the pooler URL: psql sessions, prepared statements, transactions, error handling, and the patterns that work safely inside PgBouncer transaction mode.
Some SQL doesn't fit PostgREST: schema introspection, ad-hoc queries during development, bulk imports, scheduled jobs. For those, connect directly to Postgres via the Database URL from the Connect modal. This guide covers the patterns that work, focused on the constraints of transaction-mode pooling.
For the pooler-level concerns (what breaks in transaction mode, per-driver flags), see [Connection pooling](/guides/connection-pooling). For ORM-specific setup, see the four ORM pages: [Prisma](/guides/orm-prisma), [Drizzle](/guides/orm-drizzle), [SQLAlchemy](/guides/orm-sqlalchemy), [TypeORM](/guides/orm-typeorm). For migration workflows, see [Migrations](/guides/migrations).
## The Database URL
Copy from the Connect modal in the Studio:
```
postgresql://[:@db.p.powabase.ai:5432/][
```
Username and database are both your project ref, not `postgres`. This is the PgBouncer pooler URL; there is no separate direct (non-pooled) endpoint exposed externally.
## psql from your laptop
```bash theme={null}
psql "postgresql://][:@db.p.powabase.ai:5432/]["
```
You're connected as `supabase_admin`, with full schema ownership across `public`, `ai`, `auth`, `storage`, and `extensions`. Treat this like database root access. The Database URL is a secret; never commit it, never paste it in chat.
Useful first queries to orient yourself:
```sql theme={null}
-- See your schemas
\dn
-- See your tables in public
\dt public.*
-- See the AI schema (platform-managed)
\dt ai.*
-- See your extensions
\dx
-- Check the role you're connected as
SELECT current_user, session_user;
-- → supabase_admin, supabase_admin
```
## Transactions inside the pooler
PgBouncer's transaction mode means **a server connection is held for the duration of one transaction, then returned to the pool**. The implication for your code: if you want a sequence of statements to share state (a temp table, a `SET LOCAL`, a prepared statement), wrap them in a transaction.
Outside a transaction, every statement potentially lands on a different server connection, so you can't rely on session-scoped state.
```sql theme={null}
-- WORKS — single transaction
BEGIN;
SET LOCAL statement_timeout = '5s';
CREATE TEMP TABLE staged_orders AS SELECT * FROM raw_imports;
DELETE FROM staged_orders WHERE total < 0;
INSERT INTO orders SELECT * FROM staged_orders;
COMMIT;
-- BROKEN — statements may land on different server connections
SET statement_timeout = '5s'; -- might apply to one server connection
SELECT * FROM big_table; -- might run on a different connection (no timeout)
```
For `psql` sessions this is rarely an issue, since `psql` opens a persistent connection and your statements stay on it. The pooler's transaction-mode quirks bite hardest when your driver opens new connections per query, or when a connection pool in your app rotates connections out from under you.
## Common SQL patterns
A few patterns that come up in practice.
### Bulk import from a CSV
```sql theme={null}
BEGIN;
CREATE TEMP TABLE staged_users (
email text NOT NULL,
display_name text,
created_at timestamptz DEFAULT now()
) ON COMMIT DROP;
\copy staged_users (email, display_name) FROM 'users.csv' DELIMITER ',' CSV HEADER;
INSERT INTO public.profiles (id, email, display_name)
SELECT gen_random_uuid(), email, display_name
FROM staged_users
ON CONFLICT (email) DO NOTHING;
COMMIT;
```
The `\copy` meta-command (not the SQL `COPY`) reads from your local filesystem. `ON COMMIT DROP` cleans up the temp table even though it's pooler-friendly (the whole flow is in one transaction).
### Renaming a column without breaking PostgREST clients
```sql theme={null}
BEGIN;
ALTER TABLE public.orders RENAME COLUMN status TO order_status;
-- PostgREST hot-reloads its schema cache via a NOTIFY (Powabase configures
-- this automatically); clients querying the old column name will start
-- getting 400s. For a zero-downtime rename, add the new column as a
-- generated column first, switch clients, then drop the old.
COMMIT;
```
For zero-downtime, the safer sequence is to add the new column, dual-write from triggers, migrate readers, then drop the old column. The single ALTER is fine for development; production schema changes deserve more care.
### Reading a large result without buffering
```sql theme={null}
\timing on
\set FETCH_COUNT 1000
SELECT * FROM events WHERE created_at > now() - interval '30 days';
```
`FETCH_COUNT` makes `psql` page through the result rather than buffering it all in memory. Useful for "what does this look like across millions of rows" exploration.
### Cancelling a runaway query
If you start a query and want to stop it from another session:
```sql theme={null}
-- Find your query
SELECT pid, query, state, query_start
FROM pg_stat_activity
WHERE state = 'active'
AND usename = 'supabase_admin'
ORDER BY query_start;
-- Cancel it (gentle — asks the backend to stop)
SELECT pg_cancel_backend();
-- Or kill it (hard — terminates the connection)
SELECT pg_terminate_backend();
```
`pg_cancel_backend` is the right first move; `pg_terminate_backend` is the escalation if cancel doesn't work.
## Error classes worth knowing
Postgres errors come with a 5-character SQLSTATE class. The classes you'll see most:
| SQLSTATE | Name | When |
| -------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `23505` | unique\_violation | Inserted a duplicate value for a unique index, including primary key collisions |
| `23503` | foreign\_key\_violation | Inserted a row referencing a non-existent parent, or deleted a row with children |
| `23502` | not\_null\_violation | Missed a `NOT NULL` column on insert |
| `23514` | check\_violation | A `CHECK` constraint failed |
| `40001` | serialization\_failure | Conflicting concurrent transactions at SERIALIZABLE isolation. **Retry the transaction.** |
| `40P01` | deadlock\_detected | Two transactions waiting on each other; Postgres killed one. The killed transaction should retry. |
| `42501` | insufficient\_privilege | Tried to act on something you don't have permissions for (rare as `supabase_admin`) |
| `42883` | undefined\_function | Called a function with wrong argument types, often a cast issue |
| `42P01` | undefined\_table | Table doesn't exist (or you forgot `Accept-Profile`/`search_path` for `ai.*`) |
| `26000` | invalid\_sql\_statement\_name | **PgBouncer footgun.** Your prepared statement isn't on this server connection. See [Connection pooling](/guides/connection-pooling). |
| `08006` | connection\_failure | Connection dropped. Almost always retryable. |
Most drivers expose SQLSTATE as a structured property. In Python `psycopg`:
```python theme={null}
import psycopg
from psycopg import errors
try:
cur.execute("INSERT INTO users (email) VALUES (%s)", ["alice@example.com"])
except errors.UniqueViolation:
# Handle duplicate — typically "treat as success" or "update instead"
pass
except psycopg.OperationalError as e:
if e.sqlstate in ("40001", "40P01"):
# Serialization / deadlock — retry the transaction
pass
```
The retry pattern for `40001` and `40P01` is "back off briefly, redo the entire transaction." Half-retrying a multi-statement transaction is rarely what you want, since the partial state is already gone.
## Connection lifecycle from a backend
For application servers (not psql), the right shape depends on your runtime:
**Long-running services** (Node, Python, Go on Kubernetes / VMs):
* One client-side pool per process, sized at 10-15 connections.
* Each request acquires a connection, runs its work (inside a transaction if it does more than one statement), returns the connection.
* Keep the pool alive for the lifetime of the process.
**Serverless** (Lambda, Vercel Functions, Cloudflare Workers):
* One connection per invocation. Open, transact, close.
* Don't try to reuse a connection across invocations; the runtime tears down state unpredictably, and you'll leak server connections at PgBouncer.
* Or use the HTTP-friendly PostgREST API instead. It's stateless and doesn't burn pooler slots.
**One-off scripts** (migrations, batch jobs):
* One connection, in a single transaction, then exit.
* For long migrations that hit transaction-mode constraints, see [Migrations](/guides/migrations).
## When to use direct Postgres vs PostgREST vs typed API
A rough decision tree:
| Goal | Use |
| --------------------------------------------------- | ----------------------------------------------- |
| CRUD on `public.*` with RLS, called from clients | PostgREST `/rest/v1/*` |
| Bulk INSERT/UPDATE/DELETE from a backend | Direct Postgres (faster than N PostgREST calls) |
| Schema changes (CREATE TABLE, ADD COLUMN, etc.) | Direct Postgres |
| Read-only queries with complex JOINs / aggregations | Direct Postgres or PostgREST RPC |
| AI features (run agent, search KB, upload source) | Typed `/api/*` |
| Custom analytics on `ai.*` tables | PostgREST with `Accept-Profile: ai` |
| Pub/sub-style notifications | Realtime (not LISTEN/NOTIFY through the pooler) |
The typed `/api/*` is the only path for AI operations, and PostgREST is the right path for client-side CRUD. Direct Postgres covers everything else, especially anything that touches schema or runs many statements.
## Next steps
The lower-level constraints that shape what works at the connection level.
Schema evolution patterns for hand-written SQL, Drizzle, and Prisma migrations.
The TypeScript ORM most teams reach for.
The dominant Python ORM with the migration runner Powabase teams typically use.
# Migrating from Supabase
Source: https://docs.powabase.ai/guides/migrating-from-supabase
What's identical, what's different, what breaks. Database URL format, pooler choice, the absent extensions, the ai schema as new surface, and the agentic API as the headline addition.
Powabase is a Supabase fork plus an agentic AI surface. If you've built on Supabase, most of your existing knowledge transfers. PostgREST is identical, GoTrue is the same version line, Storage is bit-compatible, and RLS works the same way. This guide covers what's different, with concrete rewriting notes for the patterns that change.
For the BaaS surface in general, see the relevant per-area pages. For the agentic API (the Powabase-specific addition), start from the [Auth & Connection](/guides/auth-connection) guide and follow the links into the AI surface.
## What's identical
The following work unchanged from Supabase. You can copy a Supabase guide verbatim and substitute the URLs.
* **PostgREST.** Same version line (v14.1 on Powabase), same filter operators, same embed syntax, same `Prefer` headers. See [PostgREST reference](/api-reference/postgrest) and [PostgREST advanced](/guides/postgrest-advanced).
* **GoTrue / Auth.** v2.184.0, with the same endpoints (`/auth/v1/*`), the same JWT shape, and the same OAuth provider list. See [Auth model](/concepts/auth-model).
* **Storage.** v1.33.0, with the same bucket/object model, TUS upload protocol, and signed URL flow. See [Storage model](/concepts/storage-model).
* **RLS patterns.** `auth.uid()`, `auth.jwt()`, `auth.role()` work identically. Policy syntax is plain Postgres. See [RLS Model](/concepts/rls-model).
* **Realtime.** v2.65.3, with three channels (Broadcast, Presence, Postgres Changes) and the same Phoenix Channels frame format. See [Realtime model](/concepts/realtime).
* **pg\_graphql.** Available via `POST /rest/v1/rpc/graphql`, not a dedicated `/graphql/v1` route (see below).
If you're porting a Supabase project, the realistic move is "copy your code, change the URL, fix the handful of differences below." Most teams need a day to ship.
## What's different
### Database URL: username and database are both `][`
Supabase URL:
```
postgresql://postgres.][:@aws-0-us-east-1.pooler.supabase.com:6543/postgres
```
Powabase URL:
```
postgresql://][:@db.p.powabase.ai:5432/][
```
Three differences:
1. **Username is `][`, not `postgres`.** Powabase's PgBouncer routes by database name, and the connection pool uses `][` as the user.
2. **Database is `][`, not `postgres`.** Same reason.
3. **Port is 5432, not 6543.** Powabase has one pooler endpoint and no separate session-mode port.
ORM connection-string flags carry over: `?pgbouncer=true&connection_limit=1` for Prisma, `?prepare=false` for Drizzle's `postgres.js`, `prepare_threshold=None` for psycopg, and so on. These are the same flags as Supabase pooler mode. See [Connection pooling](/guides/connection-pooling).
### Pooler is PgBouncer, not Supavisor
Supabase recently migrated to Supavisor. Powabase still uses PgBouncer. Practically:
* **Transaction mode only.** No session-mode endpoint on a different port. Anything that needs session state must run inside an explicit transaction.
* **No direct (non-pooled) URL.** Supavisor offers both a direct connection and a pooler-fronted one; Powabase only offers the PgBouncer URL externally. Direct Postgres is in-cluster only.
The per-driver workarounds are the same. The "transaction vs session mode" choice doesn't apply, since you're always in transaction mode.
### No `/graphql/v1` route
pg\_graphql is preloaded and the `graphql_public` schema is in PostgREST's exposed schemas. But there's no dedicated `/graphql/v1/*` Kong route, so call GraphQL through PostgREST RPC:
```bash theme={null}
POST /rest/v1/rpc/graphql
{
"query": "{ usersCollection(first: 10) { edges { node { id email } } } }",
"variables": {}
}
```
If your code targeted `/graphql/v1`, point it at `/rest/v1/rpc/graphql` and the rest works the same.
### No Edge Functions
Supabase's Deno-based Edge Functions are not deployed on Powabase. There's no `/functions/v1/*` route, no `supabase functions deploy` workflow.
What to use instead, depending on what you used Edge Functions for:
* **Auth webhooks / row triggers:** use [Database webhooks](/guides/db-webhooks) (the `supabase_functions.http_request()` trigger pattern).
* **Public HTTP endpoints:** host them yourself on a serverless platform (Vercel, Cloudflare Workers, Lambda) and call Powabase from there.
* **Background jobs:** use a [Workflow](/api-reference/workflows) with the `code` block type for the logic and a scheduled trigger.
* **The agentic AI use case:** the reason Powabase exists. Use [Agents](/api-reference/agents) directly instead of building Edge Functions that call the OpenAI API.
### `ai` schema is new
The most novel thing about Powabase is the `ai` schema. It's where the AI surface's state lives: sources, knowledge\_bases, agents, workflows, and the rest. You can query it via PostgREST under RLS (35+ tables exposed); see [Querying the ai schema](/concepts/ai-schema-postgrest).
If you're porting a Supabase project that built RAG yourself with pgvector tables in `public`, you have two options:
1. **Keep your existing tables.** `vector` is still preloaded; nothing breaks. See [User-managed pgvector](/guides/user-pgvector).
2. **Migrate to the typed surface.** Move documents to `/api/sources`, create a KB, and switch retrieval to `/api/knowledge-bases/{id}/search`. The platform handles chunking, embedding, indexing, and reranking. The trade-off is less control in exchange for more reliability and features (multiple retrieval methods, a reranker catalog, and so on).
For new projects, the typed surface is the right default. For migrations, "keep what works" is reasonable until you have a reason to change.
### `pg_cron` not available
`pg_cron` is not enabled on Powabase. If you used it for scheduled jobs:
* **Periodic table maintenance / cleanup.** Run as a workflow with a cron trigger.
* **Periodic email reports.** Same approach: a cron-triggered workflow calling a `general_api` block to your email service.
* **Per-second polling.** Probably the wrong shape. Switch to event-driven (Realtime postgres\_changes or DB webhooks).
### `pg_jsonschema` not available
Use `CHECK` constraints for simple validation, or validate at the application layer.
### Free-tier billing model differs
Supabase's free tier has soft limits (project pauses after inactivity, weekly egress quotas). Powabase's free tier has a **hard 402** on credit exhaustion (see [Billing model](/concepts/billing-model)). Different mechanism, same posture: free for prototyping, pay for production.
The 402 response includes `renews_at` (first of next UTC month). Surface that to your users so they know when their credits refresh.
### Auth proxy paths differ
Supabase's admin auth lives at `/auth/v1/admin/*`. Powabase has those endpoints too, at the same paths, plus a **control-plane proxy** at `/api/platform/auth/][/*` that proxies into the per-project GoTrue. The proxy is for Studio-internal admin operations; you almost certainly want the direct per-project endpoints. See [Auth reference](/api-reference/auth).
## What you have that Supabase doesn't
* **Agentic API:** Agents (`/api/agents`), Knowledge Bases (`/api/knowledge-bases`), Sources (`/api/sources`), Workflows (`/api/workflows`), Orchestrations (`/api/orchestrations`). The differentiating feature.
* **Five RAG indexing strategies:** chunk\_embed, full\_document, page\_index, graph\_index, doc2json. Each is an opinionated indexing pipeline.
* **Four retrieval methods:** vector\_search, full\_text (BM25), hybrid, tree\_search (for the page\_index strategy).
* **Reranker catalog:** Cohere, Jina, Voyage, ZeroEntropy options on the KB search.
* **`realtime.send()` and `realtime.broadcast_changes()`:** SQL functions for emitting broadcast messages from triggers without going through pg\_net.
See [Platform overview](/concepts/platform-overview) for the full surface.
## What you should not migrate
`ai.*`, `auth.*`, `storage.*`. The platform manages those schemas; modifying them risks breaking the corresponding services. Connect as `supabase_admin` only to migrate your own `public.*` and `extensions.*`. See [Migrations](/guides/migrations).
## Concrete porting checklist
1. **Update the Database URL:** new format, new pooler hostname.
2. **Strip `pg_cron` / `supabase functions` references:** port to workflows or self-hosted.
3. **Repoint GraphQL** from `/graphql/v1` to `/rest/v1/rpc/graphql`.
4. **Audit RLS on `ai.*`** if your app exposes the AI surface to clients; defaults are permissive for `authenticated`.
5. **Replace billing assumptions:** soft limits become a hard 402 with credit refill.
6. **Decide on RAG path:** keep your pgvector tables, or migrate to the typed Sources + KB surface.
7. **Move scheduled jobs to workflows:** cron-triggered workflows replace `pg_cron`.
For most production Supabase projects, this is half a day to a day of focused work.
## Next steps
The full Powabase surface, and what's worth knowing beyond the BaaS layer.
Terminology that overlaps confusingly.
The footgun list; most are Powabase-specific things Supabase users hit.
The Connect modal, your starting point in the Studio.
# Migrations
Source: https://docs.powabase.ai/guides/migrations
Three migration patterns for evolving your project's schema: hand-written SQL with psql, Prisma Migrate, and Drizzle Kit. Plus the boundary between your tables and the platform's.
Migrations are how you change your database schema over time without losing data. This page covers three approaches Powabase users tend to converge on: hand-written `.sql` files run through `psql`, Prisma Migrate, and Drizzle Kit. Pick whichever fits your stack. They all produce the same kind of result.
For the underlying connection setup, see [Direct Postgres patterns](/guides/direct-postgres). For the per-driver configuration each migration tool needs to coexist with the pooler, see [Connection pooling](/guides/connection-pooling).
## The platform / user schema boundary
Powabase projects ship with five schemas. **Two are yours**, three are the platform's:
| Schema | Owner | Safe to migrate? |
| ------------ | ----------- | -------------------------------------- |
| `public` | You | Yes, your application tables live here |
| `extensions` | You | Yes, add custom extensions here |
| `ai` | Platform | **No**, managed by the AI surface |
| `auth` | GoTrue | **No**, managed by GoTrue migrations |
| `storage` | Storage API | **No**, managed by the Storage API |
You can technically alter `ai.*`, `auth.*`, and `storage.*` because you connect as `supabase_admin` (the project owner) and the platform doesn't `REVOKE` those grants. **Don't.** The platform's services assume their schemas' invariants; modifying them risks data corruption or platform-side breakage.
Always scope your migrations to `public` and `extensions`. If you need to add an index to an `ai.*` table for a performance reason, file a platform issue rather than touching it directly.
## Pattern 1: Hand-written SQL through psql
A numbered directory of `.sql` files, each one a migration step. Apply them in order with `psql`. Track which have been applied in a small metadata table.
**Directory layout:**
```
migrations/
0001_create_users.sql
0002_add_user_avatar_column.sql
0003_create_posts.sql
...
```
**Bootstrap a migrations tracking table** (run once):
```sql theme={null}
CREATE TABLE IF NOT EXISTS public.schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);
```
**Wrap each migration in a transaction with the version recorded as the last step:**
```sql theme={null}
-- 0001_create_users.sql
BEGIN;
CREATE TABLE public.users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX users_email_lower_idx ON public.users (lower(email));
INSERT INTO public.schema_migrations (version) VALUES ('0001');
COMMIT;
```
**Apply migrations with a tiny shell loop:**
```bash theme={null}
#!/usr/bin/env bash
set -euo pipefail
URL="$DATABASE_URL"
for sql in migrations/*.sql; do
version=$(basename "$sql" | cut -d_ -f1)
already=$(psql "$URL" -At -c "SELECT 1 FROM public.schema_migrations WHERE version = '$version'")
if [ "$already" = "1" ]; then
echo "skip $version (already applied)"
continue
fi
echo "applying $version"
psql "$URL" -f "$sql"
done
```
The whole approach is around 30 lines of shell plus your SQL files. No framework, no metadata sync, and it's easy to inspect and debug. The trade-off: you write your own rollback logic (each migration probably needs an inverse `.down.sql` file) and you don't get auto-generation from a model.
**Pooler note.** `psql` opens a persistent connection that PgBouncer treats nicely, and your migration wraps everything in a transaction, so the transaction-mode constraints don't apply. If you have a CREATE INDEX CONCURRENTLY that needs to run outside a transaction, run it as its own migration without the BEGIN/COMMIT wrapper.
## Pattern 2: Prisma Migrate
If your stack is TypeScript / Next.js, Prisma is the path of least resistance. Schema lives in `prisma/schema.prisma`; `prisma migrate dev` generates SQL migrations from schema changes.
**`prisma/schema.prisma`**, minimal setup:
```prisma theme={null}
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
createdAt DateTime @default(now()) @map("created_at")
@@map("users")
}
```
**`.env`**, pointing at the pooler URL with Prisma's required flags:
```
DATABASE_URL="postgresql://][:@db.p.powabase.ai:5432/][?pgbouncer=true&connection_limit=1"
```
The `pgbouncer=true` flag disables prepared statements (which transaction-mode pooling breaks); `connection_limit=1` keeps Prisma from opening more connections than the pool can serve. See [Connection pooling](/guides/connection-pooling) for the full driver-flags table.
**Generate and apply a migration:**
```bash theme={null}
# In development — generates a migration file AND applies it
npx prisma migrate dev --name add_users
# In production — applies pending migrations, doesn't generate anything
npx prisma migrate deploy
```
Prisma uses an internal `_prisma_migrations` table it manages itself. Don't touch it.
**Caveat.** Prisma's `migrate dev` assumes it can open many connections (it spins up a shadow database to validate the migration). Through the pooler with `connection_limit=1`, this can hang. The workaround: run `migrate dev` against a local Postgres during development, copy the generated migration file to your repo, and only run `migrate deploy` (not `migrate dev`) against Powabase.
## Pattern 3: Drizzle Kit
For TypeScript users who want a lighter-touch ORM (closer to raw SQL, no model decorators), Drizzle is increasingly the default. Drizzle Kit generates and applies migrations from schema definitions written in TypeScript.
**`src/schema.ts`**, schema as TypeScript:
```typescript theme={null}
import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").unique().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
```
**`drizzle.config.ts`:**
```typescript theme={null}
import type { Config } from "drizzle-kit";
export default {
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies Config;
```
**`.env`**, the same URL with the `prepare=false` query param for the `postgres.js` driver Drizzle uses:
```
DATABASE_URL="postgresql://][:@db.p.powabase.ai:5432/][?prepare=false"
```
**Generate and apply:**
```bash theme={null}
# Generate a SQL migration from schema changes
npx drizzle-kit generate
# Apply pending migrations
npx drizzle-kit migrate
```
Drizzle writes migrations as plain `.sql` files in the `./drizzle` directory plus a `_journal.json` metadata file. They're the same flavor as Pattern 1, except autogenerated, so they're easy to read before you apply them.
## SQLAlchemy + Alembic
The Python equivalent. Covered on its own page: [SQLAlchemy + Alembic](/guides/orm-sqlalchemy).
## Patterns that work across all three
A few practices that apply regardless of which tool you use.
**Always wrap migrations in transactions.** All three patterns do this by default for individual statements, but if you're writing multi-statement migrations, make sure the whole change is atomic. Half-applied migrations are the worst kind of mess.
**Avoid renames; prefer "add new column → migrate readers → drop old column".** A direct `ALTER TABLE ... RENAME COLUMN` is fine in development but invalidates every cached query in PostgREST, breaks every running client, and leaves no rollback path. The 3-step pattern is slower to develop but doesn't break running services.
**Test against a real Postgres, not a mock.** Migrations interact with the entire schema: types, constraints, triggers, indexes. A mock that just records calls won't catch a `NOT NULL` violation on existing data. The fastest setup is a local Postgres container; run your migrations against it, then run your test suite.
**Backfill data carefully.** Schema changes are usually fast; data backfills can take hours. For backfills that need to run online (the app keeps serving traffic while the backfill runs), use a separate background job that pages through the table in chunks, with `LIMIT` and `WHERE id > `, rather than one massive `UPDATE`.
**For `CREATE INDEX CONCURRENTLY`, run it outside a transaction.** It can't run inside one, and the migration tools all support running specific statements outside the transactional wrapper (Prisma: edit the generated SQL file by hand; Drizzle: same; psql: just don't wrap in `BEGIN`/`COMMIT`). Concurrent indexes don't block writes during creation, which is what you want on production tables.
## When migrations fail
Each pattern handles failure differently:
**Hand-written:** if the migration's transaction rolls back, nothing changed. Re-run after fixing the SQL. If the migration committed partially (only possible with `CREATE INDEX CONCURRENTLY` outside a transaction), you have a half-done state. Re-running may be safe (`CREATE INDEX IF NOT EXISTS`) or may need manual cleanup.
**Prisma:** `migrate deploy` is idempotent, so re-running picks up where it left off. If the migration failed mid-application, Prisma marks it as `applied_steps_count < N` in `_prisma_migrations`. Resolve with `prisma migrate resolve` after manually fixing the state.
**Drizzle:** similar to Prisma. Migrations are tracked in a metadata table, and a failure leaves them marked partial. Re-running with `drizzle-kit migrate` picks up.
In all three cases, the right move after a failure is the same: stop, inspect the partial state, fix it by hand (or revert if possible), then continue. Re-running blindly after a failure can compound the damage.
## Next steps
The connection-level basics every migration tool builds on.
The PgBouncer constraints that shape per-driver migration tool config.
The full Prisma setup: schema, queries, migrations.
Drizzle setup including schema, queries, and Drizzle Kit migrations.
# Multi-Agent Orchestration
Source: https://docs.powabase.ai/guides/orchestration
Combine multiple agents into an orchestration. A coordinator routes messages to the right agent based on the conversation context.
Orchestrations let you combine specialized agents into a team. A coordinator agent decides which entity agent should handle each part of a user's request, which is what makes multi-domain conversations work.
**Prerequisites:**
* Two or more agents created (see Build an Agent guide)
Define an orchestration with a name and strategy. The supervisor strategy creates a coordinator that delegates to entity agents.
**Endpoint:** `POST /api/orchestrations`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/orchestrations",
headers=headers,
json={
"name": "Customer Support Team",
"strategy": "supervisor",
"orchestrator_config": {
"additional_instructions": "Route billing questions to the Billing agent and technical questions to the Tech Support agent.",
},
},
)
orch = response.json()
orch_id = orch["id"]
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/orchestrations`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Customer Support Team",
strategy: "supervisor",
orchestrator_config: {
additional_instructions: "Route billing questions to the Billing agent and technical questions to the Tech Support agent.",
},
}),
});
const orch = await response.json();
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/orchestrations' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "Customer Support Team", "strategy": "supervisor"}'
```
Add each agent to the orchestration with a role description that helps the coordinator decide when to delegate.
**Endpoint:** `POST /api/orchestrations/{id}/entities`
```python Python theme={null}
# Add billing agent
requests.post(
f"{BASE_URL}/api/orchestrations/{orch_id}/entities",
headers=headers,
json={
"agent_id": billing_agent_id,
"role": "Handles billing, invoices, and payment questions",
},
)
# Add tech support agent
requests.post(
f"{BASE_URL}/api/orchestrations/{orch_id}/entities",
headers=headers,
json={
"agent_id": tech_agent_id,
"role": "Handles technical issues, bugs, and setup questions",
},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/entities`, {
method: "POST",
headers,
body: JSON.stringify({
agent_id: billingAgentId,
role: "Handles billing, invoices, and payment questions",
}),
});
await fetch(`${BASE_URL}/api/orchestrations/${orchId}/entities`, {
method: "POST",
headers,
body: JSON.stringify({
agent_id: techAgentId,
role: "Handles technical issues, bugs, and setup questions",
}),
});
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/orchestrations/{orch_id}/entities' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"agent_id": "{billing_agent_id}", "role": "Handles billing questions"}'
```
Send a message to the orchestration. The coordinator delegates to the appropriate agent. Events include delegation\_started and delegation\_completed.
**Endpoint:** `POST /api/orchestrations/{id}/run/stream`
Additional SSE events for orchestrations: delegation\_started (agent name + child run ID), delegation\_completed (agent name + usage stats).
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/orchestrations/{orch_id}/run/stream",
headers=headers,
json={"message": "I have a question about my last invoice"},
stream=True,
)
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if text.startswith("data: "):
event = json.loads(text[6:])
if event["event"] == "delegation_started":
print(f"Delegating to: {event['agent']}")
elif event["event"] == "chunk":
print(event["content"], end="")
elif event["event"] == "complete":
print("\nDone.")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/orchestrations/${orchId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify({ message: "I have a question about my last invoice" }),
});
// Parse SSE stream — events include delegation_started, chunk, complete
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/orchestrations/{orch_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "I have a question about my last invoice"}'
```
## What's Next
Understand the coordinator pattern.
Full endpoint documentation.
# Drizzle
Source: https://docs.powabase.ai/guides/orm-drizzle
Use Drizzle with Powabase: connection setup using postgres.js, schema as TypeScript, queries that look like SQL, and Drizzle Kit migrations.
[Drizzle](https://orm.drizzle.team/) is a lightweight TypeScript ORM that stays close to SQL. Schema is defined in TypeScript files, queries look like SQL with type safety wrapped around them, and migrations are generated as plain `.sql` files you can inspect.
For pooler-level constraints, see [Connection pooling](/guides/connection-pooling). For broader migration patterns, see [Migrations](/guides/migrations).
## Connection setup
Drizzle works with several Postgres drivers. The most-used pairing on Powabase is `postgres.js` (a.k.a. `postgres`), which needs one flag on the URL:
```
DATABASE_URL="postgresql://][:@db.p.powabase.ai:5432/][?prepare=false"
```
* **`prepare=false`** disables `postgres.js`'s prepared-statement cache. Required for PgBouncer transaction-mode pooling.
You can also pass `{ prepare: false }` directly in client options instead of the query param:
```typescript theme={null}
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
const client = postgres(process.env.DATABASE_URL!, { prepare: false });
export const db = drizzle(client);
```
## Schema as TypeScript
`src/schema.ts`:
```typescript theme={null}
import { pgTable, uuid, text, boolean, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").unique().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const posts = pgTable("posts", {
id: uuid("id").primaryKey().defaultRandom(),
authorId: uuid("author_id").notNull().references(() => users.id),
title: text("title").notNull(),
body: text("body").notNull(),
published: boolean("published").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
```
Column names are snake\_case in SQL; the TypeScript field names are camelCase. Both are explicit, no implicit conversion.
## Queries
```typescript theme={null}
import { eq, desc, and } from "drizzle-orm";
import { db } from "./db";
import { users, posts } from "./schema";
// Insert
const [alice] = await db.insert(users)
.values({ email: "alice@example.com" })
.returning();
// Read with relation
const publishedByAlice = await db
.select({
post: posts,
authorEmail: users.email,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(and(eq(posts.authorId, alice.id), eq(posts.published, true)))
.orderBy(desc(posts.createdAt))
.limit(10);
// Update
await db.update(users)
.set({ email: "alice@new.example.com" })
.where(eq(users.id, alice.id));
// Delete
await db.delete(users).where(eq(users.id, alice.id));
// Transaction
await db.transaction(async (tx) => {
await tx.insert(users).values({ email: "bob@example.com" });
await tx.insert(posts).values({ authorId: alice.id, title: "hi", body: "world" });
});
```
The queries read like SQL because that's the design: `db.select().from().where().orderBy().limit()` maps 1:1 to `SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT`. Compared with raw SQL, Drizzle infers result types from the schema; compared with heavier ORMs, there's no magic between you and the query plan.
## Migrations with Drizzle Kit
`drizzle.config.ts`:
```typescript theme={null}
import type { Config } from "drizzle-kit";
export default {
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
} satisfies Config;
```
```bash theme={null}
# Generate a SQL migration from schema changes
npx drizzle-kit generate
# Apply pending migrations
npx drizzle-kit migrate
```
Migrations land in `./drizzle/` as `.sql` files (one per migration step) plus a `_journal.json` index. Inspect them before applying in production. Drizzle's generated SQL is straightforward, but anything that touches data is worth a second look.
The migration tracking table is `drizzle.__drizzle_migrations`. Don't touch it.
## RLS with Drizzle
Drizzle, like Prisma, connects as `supabase_admin` and bypasses RLS. To run a query under a specific user's identity for RLS-gated reads:
```typescript theme={null}
import { sql } from "drizzle-orm";
await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL ROLE authenticated`);
await tx.execute(sql`
SET LOCAL request.jwt.claims = ${JSON.stringify({
sub: userId,
role: "authenticated",
})}::jsonb
`);
return tx.select().from(users).where(eq(users.id, userId));
});
```
Inside the transaction, RLS applies. **Don't issue the SET LOCAL outside a transaction.** PgBouncer hands you a different server connection per statement, so your role and claims won't persist.
For most apps, the cleaner split is PostgREST (`/rest/v1/*`) under user JWTs for RLS-required reads from the browser, and Drizzle for server-side work under `supabase_admin`.
## Drizzle in serverless
`postgres.js` opens a real socket per call. In Lambda / Vercel Functions, the right shape is one connection per invocation:
```typescript theme={null}
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
export async function handler(event) {
const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 1 });
const db = drizzle(sql);
try {
return await db.select().from(users).where(eq(users.id, event.userId));
} finally {
await sql.end(); // Closes the connection
}
}
```
The `max: 1` keeps each invocation to one connection. Without `sql.end()`, you'll leak connections at PgBouncer until `max_client_conn = 200` cluster-wide runs out and new invocations start 429ing.
For high-traffic serverless workloads, consider Drizzle's HTTP-based drivers ([Neon serverless driver](https://neon.tech/docs/serverless/serverless-driver), Vercel Postgres). They're built for stateless invocations and don't burn pooler slots. For moderate serverless traffic on Powabase, the open-and-close pattern is fine.
## Next steps
Why `prepare=false` is required.
The migration patterns across all three ORMs we cover.
For SQL Drizzle doesn't cover: bulk imports, schema introspection.
The heavier-but-more-batteries-included TypeScript alternative.
# Prisma
Source: https://docs.powabase.ai/guides/orm-prisma
Use Prisma with Powabase's Postgres: connection setup with the pooler-required flags, a simple schema, queries, and migration workflow.
[Prisma](https://www.prisma.io/) is the most-used TypeScript ORM. It pairs well with Powabase: schema lives in `schema.prisma`, the generated client is fully typed, and Prisma Migrate handles schema evolution. The only Powabase-specific setup is the connection URL flags that PgBouncer transaction-mode pooling needs.
For the broader pooler context, see [Connection pooling](/guides/connection-pooling). For migration mechanics, see [Migrations](/guides/migrations).
## Connection setup
Copy the Database URL from the Connect modal, then add two query params:
```
DATABASE_URL="postgresql://][:@db.p.powabase.ai:5432/][?pgbouncer=true&connection_limit=1"
```
* **`pgbouncer=true`** disables Prisma's prepared-statement cache. PgBouncer in transaction mode breaks prepared statements; this flag flips Prisma to the simple query protocol.
* **`connection_limit=1`** prevents Prisma from opening more connections than your share of the project's pool (20 by default). Without it, Prisma may try to open many connections concurrently and saturate the pooler.
Put both in `.env`. Don't commit the URL.
## Minimal schema
`prisma/schema.prisma`:
```prisma theme={null}
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique
createdAt DateTime @default(now()) @map("created_at")
posts Post[]
@@map("users")
}
model Post {
id String @id @default(uuid()) @db.Uuid
authorId String @map("author_id") @db.Uuid
title String
body String
published Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
author User @relation(fields: [authorId], references: [id])
@@map("posts")
}
```
The `@@map` and `@map` lines map TypeScript camelCase to SQL snake\_case. Use them: PostgREST, your migrations, and any future raw SQL will all use the snake\_case names.
## Queries
```typescript theme={null}
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// Insert
const alice = await prisma.user.create({
data: { email: "alice@example.com" },
});
// Read with relation
const posts = await prisma.post.findMany({
where: { authorId: alice.id, published: true },
include: { author: { select: { email: true } } },
orderBy: { createdAt: "desc" },
take: 10,
});
// Update
await prisma.user.update({
where: { id: alice.id },
data: { email: "alice@new.example.com" },
});
// Delete (cascades by foreign-key default; configure with onDelete in schema)
await prisma.user.delete({ where: { id: alice.id } });
// Transaction
await prisma.$transaction([
prisma.user.create({ data: { email: "bob@example.com" } }),
prisma.post.create({ data: { authorId: alice.id, title: "hi", body: "world" } }),
]);
```
## Migrations
```bash theme={null}
# Development — generates AND applies a migration
npx prisma migrate dev --name add_users
# Production — only applies pending migrations
npx prisma migrate deploy
```
`migrate dev` doesn't play well with the pooler: it tries to open a shadow database, and `connection_limit=1` can cause hangs. The pattern most teams use:
1. Run `migrate dev` against a **local Postgres** during development (point `DATABASE_URL` at a local container, not Powabase).
2. Commit the generated SQL migration files to your repo.
3. In CI/CD against Powabase, only run `migrate deploy`.
Prisma's `_prisma_migrations` tracking table handles the rest. See [Migrations](/guides/migrations) for failure recovery.
## RLS and Prisma
Prisma connects as `supabase_admin` (full schema ownership, bypasses RLS). The client cannot impersonate other roles; if your app needs RLS-respecting queries from your backend, your options are:
1. **Use the PostgREST API** (`/rest/v1/*`) with the user's access token. PostgREST sets the role from the JWT. Don't use Prisma for that subset of queries.
2. **Set the role manually in a transaction:**
```typescript theme={null}
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET LOCAL ROLE authenticated`;
await tx.$executeRaw`SET LOCAL request.jwt.claims = ${JSON.stringify({
sub: userId,
role: "authenticated",
})}::jsonb`;
return tx.user.findMany();
});
```
Inside the transaction, RLS applies as it would for any other `authenticated` request. **Both `SET LOCAL` statements must be inside the same transaction as the queries.** PgBouncer transaction mode is what makes this safe.
For most backend code, option 1 (direct PostgREST for RLS-required reads, Prisma for everything else) is cleaner than option 2.
## Serverless gotchas
Lambda, Vercel Functions, and Cloudflare Workers tear down state between invocations. Prisma's default `PrismaClient` instance holds connections open across invocations expecting a long-running process; in serverless, each cold-start opens new connections and leaks them.
Two paths:
1. **Use Prisma's Data Proxy / Accelerate.** Prisma routes queries through a hosted connection pool. Compatible with serverless. Costs money.
2. **Open and close per invocation.** Create `new PrismaClient()` at the top of the handler, `await prisma.$disconnect()` before returning. Burns the cold-start time on every invocation, but doesn't leak connections.
For high-traffic serverless apps, neither option is great. Consider whether the Postgres-direct shape is the right one at all. For read-heavy workloads, PostgREST through Powabase's existing scale is often the simpler answer.
## Next steps
Why the Prisma flags above are needed.
The migration patterns across all three ORMs we cover.
For SQL Prisma doesn't express: bulk imports, schema introspection, and the like.
A lighter-touch TypeScript alternative.
# SQLAlchemy + Alembic
Source: https://docs.powabase.ai/guides/orm-sqlalchemy
Use SQLAlchemy with Powabase: connection setup with psycopg, declarative models, queries, sessions, and Alembic migrations.
[SQLAlchemy](https://www.sqlalchemy.org/) is the dominant Python ORM. Most Powabase teams writing Python use it. The migration runner that pairs with SQLAlchemy is [Alembic](https://alembic.sqlalchemy.org/), maintained by the same author. This guide covers both.
For pooler-level constraints, see [Connection pooling](/guides/connection-pooling). For migration patterns shared across ORMs, see [Migrations](/guides/migrations).
## Connection setup
SQLAlchemy v2 with the `psycopg` (v3) driver is the most common pairing on modern projects. The Database URL becomes a SQLAlchemy URL by changing the prefix:
```python theme={null}
import os
from sqlalchemy import create_engine
DATABASE_URL = os.environ["DATABASE_URL"]
# Original: postgresql://][:@db.p.powabase.ai:5432/][
# For psycopg v3, swap the dialect:
SQLA_URL = DATABASE_URL.replace("postgresql://", "postgresql+psycopg://", 1)
engine = create_engine(
SQLA_URL,
# Disable SQLAlchemy's own connection pool — PgBouncer is already pooling.
# NullPool gives us "one connection per checkout, closed on return," which
# is the right shape on top of an external pooler.
poolclass=__import__("sqlalchemy.pool", fromlist=["NullPool"]).NullPool,
connect_args={
# Disable psycopg's auto-prepare. PgBouncer transaction mode breaks
# prepared statements; prepare_threshold=None disables them entirely.
"prepare_threshold": None,
},
)
```
Two flags doing real work:
* **`poolclass=NullPool`** turns off SQLAlchemy's own pool. The default `QueuePool` keeps connections alive across requests, but PgBouncer already does that. Stacking two pools wastes connections and complicates debugging. `NullPool` opens a connection per checkout and closes it on return.
* **`connect_args={"prepare_threshold": None}`** disables `psycopg` v3's auto-prepare. Without it, you'll get sporadic `prepared statement "..." does not exist` errors at runtime.
For `psycopg2` (the older C-based driver), the URL prefix is `postgresql+psycopg2://` and the prepared-statement flag isn't needed, since `psycopg2` doesn't auto-prepare. New projects should prefer `psycopg` v3.
## Declarative models
```python theme={null}
import uuid
from datetime import datetime, timezone
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
posts: Mapped[list["Post"]] = relationship("Post", back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
author_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
title: Mapped[str] = mapped_column(String, nullable=False)
body: Mapped[str] = mapped_column(String, nullable=False)
published: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
author: Mapped[User] = relationship("User", back_populates="posts")
```
`Mapped` and `mapped_column` are the v2-style annotations. They give you fully-typed model attributes: `user.email: str`, not `Column[str]`.
## Sessions and queries
```python theme={null}
from sqlalchemy.orm import Session
from sqlalchemy import select
with Session(engine) as session:
# Insert
alice = User(email="alice@example.com")
session.add(alice)
session.commit()
# Read with relation
stmt = (
select(Post)
.join(User)
.where(Post.author_id == alice.id, Post.published.is_(True))
.order_by(Post.created_at.desc())
.limit(10)
)
posts = session.scalars(stmt).all()
# Update
alice.email = "alice@new.example.com"
session.commit()
# Delete
session.delete(alice)
session.commit()
```
The `with Session(engine) as session:` context manager handles connection lifecycle correctly for `NullPool`: the connection is opened on the first query and returned on exit. Don't reuse a session across HTTP requests.
For web apps, use the per-request session pattern your framework provides (Flask-SQLAlchemy's `db.session`, FastAPI's `Depends(get_db)` dependency, and so on). Each wraps `Session(engine)` and ensures cleanup.
## Alembic migrations
`alembic.ini` (after `alembic init alembic`):
```ini theme={null}
sqlalchemy.url = postgresql+psycopg://][:@db.p.powabase.ai:5432/][
```
In practice, you'll read the URL from an env var rather than hardcoding. In `alembic/env.py`:
```python theme={null}
import os
from sqlalchemy import create_engine, pool
from alembic import context
from your_app.db import Base # so Alembic sees your models
DATABASE_URL = os.environ["DATABASE_URL"].replace("postgresql://", "postgresql+psycopg://", 1)
target_metadata = Base.metadata
def run_migrations_online() -> None:
connectable = create_engine(
DATABASE_URL,
poolclass=pool.NullPool,
connect_args={"prepare_threshold": None},
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
```
The same `NullPool` + `prepare_threshold=None` pattern as your app's engine. Alembic's autogenerate diffs your models against the live database and writes a migration file:
```bash theme={null}
# Autogenerate a migration from model changes
alembic revision --autogenerate -m "add users"
# Apply pending migrations
alembic upgrade head
# Roll back one migration
alembic downgrade -1
# See where you are
alembic current
```
The generated migration file in `alembic/versions/` is plain Python, so review it before applying. Autogenerate is usually right but occasionally misses subtleties: column renames look like drop+add, custom check constraints aren't picked up, and so on.
Alembic tracks state in `alembic_version` (a single-row table). Don't touch it.
## RLS from SQLAlchemy
The connection is `supabase_admin`, bypassing RLS. To run queries as a specific user:
```python theme={null}
import json
from sqlalchemy import text
with Session(engine) as session:
session.begin() # Explicit transaction — SET LOCAL needs one
session.execute(text("SET LOCAL ROLE authenticated"))
session.execute(text(
"SET LOCAL request.jwt.claims = :claims::jsonb"
), {"claims": json.dumps({"sub": str(user_id), "role": "authenticated"})})
# Now RLS applies to these queries
posts = session.scalars(select(Post).where(Post.author_id == user_id)).all()
session.commit()
```
The `SET LOCAL` statements must be in the same transaction as the queries. This works for read-mostly backend code. For per-request RLS, the cleaner split is to use PostgREST (`/rest/v1/*`) under the user's JWT for those reads and SQLAlchemy as `supabase_admin` for everything else.
## SQLAlchemy in async
For async apps (FastAPI with `asyncio`, and the like), use the async engine and `psycopg`'s async support:
```python theme={null}
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
async_engine = create_async_engine(
DATABASE_URL.replace("postgresql://", "postgresql+psycopg_async://", 1),
poolclass=NullPool,
connect_args={"prepare_threshold": None},
)
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)
async def list_posts(user_id):
async with AsyncSessionLocal() as session:
result = await session.scalars(select(Post).where(Post.author_id == user_id))
return result.all()
```
For `asyncpg` (a different async driver), use `statement_cache_size=0` instead of `prepare_threshold=None`:
```python theme={null}
async_engine = create_async_engine(
DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1),
poolclass=NullPool,
connect_args={"statement_cache_size": 0},
)
```
Both flags do the same thing: disable prepared statements for pooler compatibility.
## Next steps
Why `NullPool` and `prepare_threshold=None` are required.
Alembic in the context of the other ORMs' migration tools.
For SQL SQLAlchemy doesn't express: bulk imports, schema introspection.
The other ORM Python-and-Node teams sometimes share.
# TypeORM
Source: https://docs.powabase.ai/guides/orm-typeorm
Use TypeORM with Powabase: DataSource setup for the pooler, decorator-based entities, queries via the repository pattern, and TypeORM migrations.
[TypeORM](https://typeorm.io/) is a decorator-based TypeScript ORM, popular in NestJS apps and other framework-first stacks. Its model definitions and migration runner work well with Powabase as long as you turn off TypeORM's prepared-statement caching at the pooler level.
For pooler-level constraints, see [Connection pooling](/guides/connection-pooling). For migration patterns shared across ORMs, see [Migrations](/guides/migrations).
## DataSource setup
`src/data-source.ts`:
```typescript theme={null}
import "reflect-metadata";
import { DataSource } from "typeorm";
import { User } from "./entities/User";
import { Post } from "./entities/Post";
export const AppDataSource = new DataSource({
type: "postgres",
url: process.env.DATABASE_URL,
entities: [User, Post],
migrations: ["src/migrations/*.ts"],
// TypeORM uses node-postgres under the hood; disable prepared statements
// for PgBouncer transaction-mode compatibility.
extra: {
statement_timeout: 30_000,
application_name: "your-app-name",
},
// Cap the pool to avoid saturating PgBouncer
poolSize: 10,
// Prepared statements are off by default in node-postgres unless explicitly
// enabled; nothing extra needed here for that.
});
await AppDataSource.initialize();
```
`poolSize: 10` keeps your app's connection share at half of PgBouncer's `default_pool_size = 20`, leaving room for migrations and other workloads.
If you're using `pg` directly (TypeORM's default for `type: "postgres"`), prepared statements aren't enabled unless you call `client.query()` with the `name` option, which TypeORM's repository methods don't do. So unlike Prisma and Drizzle, no explicit `prepare=false` flag is needed. Just don't switch to a driver that does auto-prepare.
## Entities
`src/entities/User.ts`:
```typescript theme={null}
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn } from "typeorm";
import { Post } from "./Post";
@Entity({ name: "users" })
export class User {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "text", unique: true })
email!: string;
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
createdAt!: Date;
@OneToMany(() => Post, (post) => post.author)
posts!: Post[];
}
```
`src/entities/Post.ts`:
```typescript theme={null}
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from "typeorm";
import { User } from "./User";
@Entity({ name: "posts" })
export class Post {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "author_id" })
authorId!: string;
@Column({ type: "text" })
title!: string;
@Column({ type: "text" })
body!: string;
@Column({ type: "boolean", default: false })
published!: boolean;
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
createdAt!: Date;
@ManyToOne(() => User, (user) => user.posts)
@JoinColumn({ name: "author_id" })
author!: User;
}
```
The `{ name: "users" }` / `{ name: "created_at" }` overrides map the TypeScript names to snake\_case SQL. Match whatever convention you've set for the rest of your schema.
## Queries via repositories
```typescript theme={null}
import { AppDataSource } from "./data-source";
import { User } from "./entities/User";
import { Post } from "./entities/Post";
const userRepo = AppDataSource.getRepository(User);
const postRepo = AppDataSource.getRepository(Post);
// Insert
const alice = await userRepo.save({ email: "alice@example.com" });
// Read with relation
const posts = await postRepo.find({
where: { authorId: alice.id, published: true },
relations: { author: true },
order: { createdAt: "DESC" },
take: 10,
});
// Update
await userRepo.update(alice.id, { email: "alice@new.example.com" });
// Delete
await userRepo.delete(alice.id);
// Transaction
await AppDataSource.transaction(async (tx) => {
await tx.getRepository(User).save({ email: "bob@example.com" });
await tx.getRepository(Post).save({ authorId: alice.id, title: "hi", body: "world" });
});
```
For complex queries, use the QueryBuilder. It's the closest TypeORM gets to raw SQL:
```typescript theme={null}
const recentPosts = await postRepo
.createQueryBuilder("post")
.innerJoinAndSelect("post.author", "author")
.where("post.published = :published", { published: true })
.andWhere("post.created_at > :since", { since: thirtyDaysAgo })
.orderBy("post.created_at", "DESC")
.take(50)
.getMany();
```
## Migrations
TypeORM has its own migration runner. Generate from current schema vs entities:
```bash theme={null}
# Generate a migration based on entity changes
npx typeorm migration:generate src/migrations/AddUsers -d src/data-source.ts
# Apply pending migrations
npx typeorm migration:run -d src/data-source.ts
# Roll back the last migration
npx typeorm migration:revert -d src/data-source.ts
```
Migrations land in `src/migrations/` as TypeScript classes implementing `MigrationInterface`. As with Drizzle, the generated migration is largely SQL, so it's easy to inspect before applying.
TypeORM's tracking table is `migrations`. Don't touch it.
One autogenerate caveat is worth knowing: TypeORM compares your entities against the live database schema, so **you need a development database that matches your production schema** for autogenerate to produce a clean diff. Most teams keep a local Postgres pinned to production's schema for this.
## RLS from TypeORM
`supabase_admin` connection, bypasses RLS. For RLS-respecting queries, the same transaction-with-SET-LOCAL pattern as Prisma and Drizzle:
```typescript theme={null}
await AppDataSource.transaction(async (tx) => {
await tx.query("SET LOCAL ROLE authenticated");
await tx.query(
"SET LOCAL request.jwt.claims = $1::jsonb",
[JSON.stringify({ sub: userId, role: "authenticated" })],
);
return tx.getRepository(User).find({ where: { id: userId } });
});
```
Inside the transaction, RLS applies. The `tx.query` calls here are the raw-SQL escape hatch on the transactional connection.
## TypeORM in NestJS
In a NestJS project, the standard wiring is `TypeOrmModule.forRoot()` in your app module:
```typescript theme={null}
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
@Module({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
url: process.env.DATABASE_URL,
entities: [__dirname + "/**/*.entity.ts"],
migrationsRun: false,
poolSize: 10,
}),
],
})
export class AppModule {}
```
Then inject repositories into your services:
```typescript theme={null}
@Injectable()
export class UsersService {
constructor(@InjectRepository(User) private users: Repository) {}
findById(id: string) {
return this.users.findOne({ where: { id } });
}
}
```
NestJS handles the request-scoped lifecycle, so you don't need to think about checkouts.
## Next steps
The PgBouncer constraints `poolSize: 10` and disabled-prepares work around.
TypeORM's runner in the context of the other ORMs.
For SQL TypeORM doesn't express: bulk imports, schema introspection.
The TypeScript ORM most teams default to today.
# PostgREST advanced
Source: https://docs.powabase.ai/guides/postgrest-advanced
Beyond basic CRUD: embedded joins, full-text search, JSONB operators, counting, upserts, bulk inserts, and the headers that change response shape.
The [PostgREST reference](/api-reference/postgrest) covers filter operators and the basic CRUD shape. This guide picks up where that leaves off: embedded resources, FTS, JSONB selectors, counting, upserts, bulk inserts, and the `Prefer` headers that change how responses come back.
For the framing, see [PostgREST reference](/api-reference/postgrest). For applying these patterns to the AI schema, see [ai-schema recipes](/guides/ai-schema-recipes).
## Embedded resources (joins in one request)
PostgREST infers joins from foreign keys. Use `select=` with a relation name to embed:
```bash theme={null}
GET /rest/v1/posts?select=id,title,author:users(email,display_name)
```
Response:
```json theme={null}
[
{ "id": "p1", "title": "hello", "author": { "email": "alice@example.com", "display_name": "Alice" } }
]
```
The `author:users(...)` syntax aliases the embedded `users` row as `author`. The columns inside parens are projected from the joined table.
**Many-to-many:** if `users` has many `posts` via an `author_id` FK, embed in the other direction:
```bash theme={null}
GET /rest/v1/users?select=id,email,posts(id,title)
```
Response is an array per user with a `posts` array of joined rows.
**Filtering on the embed:**
```bash theme={null}
GET /rest/v1/users?select=email,posts(title)&posts.published=eq.true
```
Only published posts come back inside each user's `posts` array. The filter on the embed uses the dotted-path syntax.
**Inner joins** (exclude users with no posts):
```bash theme={null}
GET /rest/v1/users?select=email,posts!inner(title)&posts.published=eq.true
```
The `!inner` modifier makes the embed an INNER JOIN instead of LEFT JOIN.
## Full-text search
PostgREST supports four FTS operators that map to Postgres's text-search functions:
| Operator | Maps to | Use | |
| -------- | ------------------------- | --------------------------------------- | -------- |
| `fts` | `@@ to_tsquery` | Boolean queries with operators (`&`, \` | `, `!\`) |
| `plfts` | `@@ plainto_tsquery` | Treat input as plain text; no operators | |
| `phfts` | `@@ phraseto_tsquery` | Phrase search; word order matters | |
| `wfts` | `@@ websearch_to_tsquery` | Google-style ("foo bar" OR baz) | |
```bash theme={null}
# Plain-text search across an indexed column
GET /rest/v1/posts?body=plfts.machine.learning
# Phrase search
GET /rest/v1/posts?body=phfts.machine.learning
# Web-style with quoted phrases and OR
GET /rest/v1/posts?body=wfts."machine learning".OR.AI
```
These require a `tsvector` column or expression index for performance. The bare `body=plfts.foo` form scans every row's text. Fine for prototypes, terrible at scale.
## JSONB operators
PostgreSQL's JSONB operators work in filters via PostgREST's syntax:
```bash theme={null}
# meta->>'tag' = 'foo' (text comparison)
GET /rest/v1/sources?meta->>tag=eq.foo
# meta->'tags' ? 'foo' (does the array contain 'foo')
GET /rest/v1/sources?meta->tags=cs.{foo}
# meta @> '{"tag":"foo"}' (contains)
GET /rest/v1/sources?meta=cs.{"tag":"foo"}
# meta <@ '{"tag":"foo"}' (contained by)
GET /rest/v1/sources?meta=cd.{"tag":"foo"}
```
The `cs` (contains) and `cd` (contained by) operators work on both JSONB and array columns.
For complex queries (multiple JSONB conditions, indexing strategies), check the [Postgres JSON docs](https://www.postgresql.org/docs/current/functions-json.html) for the operator semantics and create matching GIN indexes (`CREATE INDEX ... USING gin (meta jsonb_path_ops)`).
## Counting
By default, PostgREST returns the matching rows without a total count, since counting can be expensive. Opt in via the `Prefer` header:
```bash theme={null}
curl '{BASE_URL}/rest/v1/posts?select=*' \
-H "Prefer: count=exact" \
-H "apikey: " -H "Authorization: Bearer "
```
Three count modes:
* **`count=exact`**: runs a separate `COUNT(*)` query. Accurate, slowest. The total comes back in the `Content-Range: 0-9/247` response header.
* **`count=planned`**: uses Postgres's planner estimate. Fast, imprecise.
* **`count=estimated`**: runs `COUNT(*)` only if planned > some threshold; planner estimate otherwise.
For paginated UIs, prefer `count=planned` unless the user explicitly asks for an exact total.
## Upserts
POST with `Prefer: resolution=merge-duplicates` does INSERT-or-UPDATE:
```bash theme={null}
curl -X POST '{BASE_URL}/rest/v1/users' \
-H "Prefer: resolution=merge-duplicates" \
-H "Content-Type: application/json" \
-H "apikey: " -H "Authorization: Bearer " \
-d '{"email": "alice@example.com", "display_name": "Alice 2.0"}'
```
If `email` is a unique column and a row already exists with that email, the existing row updates. Otherwise inserts.
For composite conflict columns, use `on_conflict=col1,col2`:
```bash theme={null}
POST /rest/v1/votes?on_conflict=user_id,poll_id
```
## Bulk inserts
POST an array instead of a single object:
```bash theme={null}
curl -X POST '{BASE_URL}/rest/v1/users' \
-H "Content-Type: application/json" \
-H "apikey: " -H "Authorization: Bearer " \
-d '[
{"email": "alice@example.com"},
{"email": "bob@example.com"},
{"email": "charlie@example.com"}
]'
```
By default this is a single transaction: all succeed or all roll back. Set `Prefer: tx=rollback` to test without committing, or `Prefer: missing=default` to allow rows with omitted columns to use their defaults instead of failing.
## Response shape control
`Prefer: return=...` changes what comes back from a write:
| Value | Response body | When |
| ----------------------------------------- | ------------------------- | ---------------------------------------------- |
| `return=minimal` (default for POST/PATCH) | empty | You don't need the inserted/updated rows back |
| `return=representation` | the rows | You need the rows (e.g., for the generated id) |
| `return=headers-only` | empty + `Location` header | RPC-style "tell me where the new row is" |
For SELECT-after-INSERT patterns, `Prefer: return=representation` is what you want. It returns the inserted rows complete with database-generated columns.
## Single-row responses
PostgREST returns arrays by default. To get a single object instead of `[{...}]`, send `Accept: application/vnd.pgrst.object+json`:
```bash theme={null}
curl '{BASE_URL}/rest/v1/users?id=eq.' \
-H "Accept: application/vnd.pgrst.object+json"
```
Returns `{...}` directly. **Fails with 406 if the filter doesn't match exactly one row.** Use this when you want to fail loudly if you expected one row and got zero or many.
## Partial response columns
The `Range` header lets you do offset-based pagination at the response level:
```bash theme={null}
curl '{BASE_URL}/rest/v1/posts?order=created_at.desc' \
-H "Range-Unit: items" \
-H "Range: 0-19"
```
Returns items 0 through 19. Combine with `Prefer: count=exact` and the `Content-Range` response header tells the client how many total items exist.
For cursor-style pagination, which is friendlier to large tables, use `id` comparisons instead: `?id=gt.&limit=20`.
## RPC for non-CRUD logic
For anything that needs SQL beyond filter/sort/paginate, write a function and call it via RPC:
```sql theme={null}
CREATE OR REPLACE FUNCTION public.recent_active_users(days int)
RETURNS TABLE (id uuid, email text, last_seen timestamptz)
LANGUAGE sql
STABLE
AS $$
SELECT id, email, last_sign_in_at
FROM auth.users
WHERE last_sign_in_at > now() - (days || ' days')::interval
ORDER BY last_sign_in_at DESC
$$;
GRANT EXECUTE ON FUNCTION public.recent_active_users(int) TO authenticated;
```
Call:
```bash theme={null}
POST /rest/v1/rpc/recent_active_users
{ "days": 7 }
```
RPCs are how you keep complex logic in the database (where it has full SQL power, indexes, and consistent transactions) without exposing a custom endpoint.
## Next steps
The base CRUD surface these patterns extend.
The same techniques applied to ai.\* tables.
Policies that gate which rows these queries see.
For SQL PostgREST doesn't express, drop down to a direct connection.
# Quickstart
Source: https://docs.powabase.ai/guides/quickstart
Build an end-to-end RAG agent in 5 minutes, from document upload to a streaming conversation.
In this guide you will upload a document, create a knowledge base, index the document into it, spin up an agent backed by that knowledge base, and run a streaming conversation, all through the REST API. By the end you will have a RAG agent that answers questions grounded in your own content.
**Prerequisites:**
* A Powabase project — grab your Project URL and Service Role (Secret) Key from the Connect modal in the Studio (click the Connect button in your project header, or append ?showConnect=true to any project URL). See the Auth & Connection guide for the full walkthrough.
Set up your base URL and authentication headers. Copy Project URL and Service Role (Secret) Key from the Studio's Connect modal. Every /api/\* request needs the service role key in both the apikey and Authorization headers.
**Endpoint:** `Headers: apikey + Authorization`
```python Python theme={null}
import requests
BASE_URL = "{BASE_URL}" # Connect modal -> Project URL
API_KEY = "{API_KEY}" # Connect modal -> Service Role (Secret) Key
headers = {
"apikey": API_KEY,
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
```
```typescript TypeScript theme={null}
const BASE_URL = "{BASE_URL}"; // Connect modal -> Project URL
const API_KEY = "{API_KEY}"; // Connect modal -> Service Role (Secret) Key
const headers = {
apikey: API_KEY,
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};
```
```bash cURL theme={null}
# Set these variables for the rest of the guide.
# Both values come from the Studio's Connect modal:
# BASE_URL = Project URL
# API_KEY = Service Role (Secret) Key
BASE_URL="{BASE_URL}"
API_KEY="{API_KEY}"
```
Upload a file to create a Source. The platform automatically extracts its text content for indexing.
**Endpoint:** `POST /api/sources/upload`
```python Python theme={null}
with open("product-docs.pdf", "rb") as f:
response = requests.post(
f"{BASE_URL}/api/sources/upload",
headers={"apikey": API_KEY, "Authorization": f"Bearer {API_KEY}"},
files={"file": ("product-docs.pdf", f, "application/pdf")},
)
source = response.json()
source_id = source["id"]
print(f"Source created: {source_id}, status: {source['extraction_status']}")
# Poll until extraction completes
import time
TERMINAL = {"extracted", "attention_required", "failed", "cancelled"}
while True:
res = requests.get(f"{BASE_URL}/api/sources/{source_id}", headers=headers)
status = res.json()["extraction_status"]
if status in TERMINAL:
print(f"Extraction ended with status: {status}")
break
time.sleep(2)
```
```typescript TypeScript theme={null}
const formData = new FormData();
formData.append("file", fileBlob, "product-docs.pdf");
const uploadRes = await fetch(`${BASE_URL}/api/sources/upload`, {
method: "POST",
headers: { apikey: API_KEY, Authorization: `Bearer ${API_KEY}` },
body: formData,
});
const source = await uploadRes.json();
const sourceId = source.id;
console.log("Source created:", sourceId);
// Poll until extraction reaches a terminal state
const TERMINAL = new Set(["extracted", "attention_required", "failed", "cancelled"]);
let status = "pending";
while (!TERMINAL.has(status)) {
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}`, { headers });
status = (await res.json()).extraction_status;
}
console.log("Extraction ended with status:", status);
```
```bash cURL theme={null}
# Upload the document
curl -X POST '{BASE_URL}/api/sources/upload' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-F "file=@product-docs.pdf"
# Poll extraction status (replace {source_id})
curl '{BASE_URL}/api/sources/{source_id}' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
```
**Response:**
```json theme={null}
{
"id": "source-uuid",
"name": "product-docs.pdf",
"file_type": "application/pdf",
"storage_path": "sources-{org}-{project}/{source_id}/product-docs.pdf",
"extraction_status": "pending",
"task_id": "celery-task-uuid"
}
```
Create a knowledge base, then add the source to it. Adding a source triggers chunking and vector indexing automatically.
**Endpoint:** `POST /api/knowledge-bases`
```python Python theme={null}
# Create the knowledge base
response = requests.post(
f"{BASE_URL}/api/knowledge-bases",
headers=headers,
json={
"name": "Product Docs",
"description": "Product documentation knowledge base",
},
)
kb = response.json()
kb_id = kb["id"]
print(f"Knowledge base created: {kb_id}")
# Add the source to trigger indexing
response = requests.post(
f"{BASE_URL}/api/knowledge-bases/{kb_id}/sources",
headers=headers,
json={"source_id": source_id},
)
print(f"Source added, indexing started: {response.json()}")
```
```typescript TypeScript theme={null}
// Create the knowledge base
const kbRes = await fetch(`${BASE_URL}/api/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Product Docs",
description: "Product documentation knowledge base",
}),
});
const kb = await kbRes.json();
const kbId = kb.id;
console.log("Knowledge base created:", kbId);
// Add the source to trigger indexing
await fetch(`${BASE_URL}/api/knowledge-bases/${kbId}/sources`, {
method: "POST",
headers,
body: JSON.stringify({ source_id: sourceId }),
});
console.log("Source added, indexing started");
```
```bash cURL theme={null}
# Create the knowledge base
curl -X POST '{BASE_URL}/api/knowledge-bases' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Product Docs",
"description": "Product documentation knowledge base"
}'
# Add source to trigger indexing (replace {kb_id})
curl -X POST '{BASE_URL}/api/knowledge-bases/{kb_id}/sources' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"source_id": "{source_id}"}'
```
Create an agent and link the knowledge base to it. The agent automatically gets a search tool for each linked knowledge base.
**Endpoint:** `POST /api/agents`
```python Python theme={null}
# Create the agent
response = requests.post(
f"{BASE_URL}/api/agents",
headers=headers,
json={
"name": "Docs Assistant",
"model": "gpt-4o",
"system_prompt": "You are a helpful assistant. Use the knowledge base to answer questions about our product documentation.",
"settings": {"temperature": 0.7},
},
)
agent = response.json()
agent_id = agent["id"]
print(f"Agent created: {agent_id}")
# Link the knowledge base
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/knowledge-bases",
headers=headers,
json={"knowledge_base_id": kb_id},
)
print(f"Knowledge base linked: {response.json()}")
```
```typescript TypeScript theme={null}
// Create the agent
const agentRes = await fetch(`${BASE_URL}/api/agents`, {
method: "POST",
headers,
body: JSON.stringify({
name: "Docs Assistant",
model: "gpt-4o",
system_prompt: "You are a helpful assistant. Use the knowledge base to answer questions about our product documentation.",
settings: { temperature: 0.7 },
}),
});
const agent = await agentRes.json();
const agentId = agent.id;
console.log("Agent created:", agentId);
// Link the knowledge base
await fetch(`${BASE_URL}/api/agents/${agentId}/knowledge-bases`, {
method: "POST",
headers,
body: JSON.stringify({ knowledge_base_id: kbId }),
});
console.log("Knowledge base linked");
```
```bash cURL theme={null}
# Create the agent
curl -X POST '{BASE_URL}/api/agents' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "Docs Assistant",
"model": "gpt-4o",
"system_prompt": "You are a helpful assistant.",
"settings": {"temperature": 0.7}
}'
# Link knowledge base (replace {agent_id})
curl -X POST '{BASE_URL}/api/agents/{agent_id}/knowledge-bases' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"knowledge_base_id": "{kb_id}"}'
```
Send a message and consume the SSE stream. The agent will search the knowledge base, reason about the results, and stream back an answer.
**Endpoint:** `POST /api/agents/{id}/run/stream`
The agent will emit tool\_call and tool\_result events as it searches the knowledge base, followed by chunk events containing the streamed answer.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json={"message": "How do I get started with the product?"},
stream=True,
)
import json
session_id = None
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if text.startswith("data: "):
event = json.loads(text[6:])
if event["event"] == "start":
session_id = event["session_id"]
elif event["event"] == "chunk":
print(event["content"], end="")
elif event["event"] == "tool_call":
print(f"\n[Searching: {event['tool_name']}]")
elif event["event"] == "tool_result":
print(f"[Results received]")
elif event["event"] == "complete":
print(f"\n\nDone! Session: {session_id}")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify({
message: "How do I get started with the product?",
}),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let sessionId: string | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
for (const line of text.split("\n")) {
if (line.startsWith("data: ")) {
const event = JSON.parse(line.slice(6));
if (event.event === "start") sessionId = event.session_id;
if (event.event === "chunk") process.stdout.write(event.content);
if (event.event === "tool_call") console.log(`\n[Searching: ${event.tool_name}]`);
if (event.event === "tool_result") console.log("[Results received]");
if (event.event === "complete") console.log(`\nDone! Session: ${sessionId}`);
}
}
}
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "How do I get started with the product?"}'
```
## What's Next
Understand the ReAct loop, tool types, and how agents reason.
Deep dive into SSE event handling and multi-turn sessions.
Full endpoint documentation for agents.
# Realtime subscriptions
Source: https://docs.powabase.ai/guides/realtime-subscriptions
Three worked patterns: a live-updating list driven by postgres_changes, a presence-based 'who's online' indicator, and a Broadcast chat.
These recipes assume you've read [Realtime model](/concepts/realtime) and have either the Anon Key (for unauthenticated demos) or a user access token (for production patterns). They use TypeScript in the browser as the main example, since Realtime is overwhelmingly a client-side tool. The Python and cURL approaches at the bottom of each section show how to verify and drive Realtime from outside the browser when you need to.
For the WebSocket protocol details (frame shapes, error responses), see [Realtime Reference](/api-reference/realtime). For the conceptual underpinning, see [Realtime model](/concepts/realtime).
## Setup
All three recipes share the same connection setup. We use a tiny WebSocket helper rather than the upstream `@supabase/realtime-js` library because Powabase doesn't ship its own SDK yet. Once you have a working WS client, the protocol from there is straightforward.
```typescript theme={null}
const BASE_URL = "wss://{ref}.p.powabase.ai";
const ANON_KEY = "";
function connect(token: string = ANON_KEY): WebSocket {
const url = `${BASE_URL}/realtime/v1/websocket?apikey=${token}&vsn=1.0.0`;
return new WebSocket(url);
}
// Phoenix-style frame format
function send(ws: WebSocket, ref: string, topic: string, event: string, payload: object) {
ws.send(JSON.stringify({ topic, event, payload, ref }));
}
```
The `vsn=1.0.0` parameter tells Realtime which protocol version to use. The frames are Phoenix Channels JSON envelopes: `{ topic, event, payload, ref }`. The `ref` is your client-generated request ID; Realtime echoes it back so you can correlate replies.
## Recipe 1: Live-updating list with Postgres Changes
A todo list where INSERT/UPDATE/DELETE on `public.todos` automatically updates the UI. The classic Postgres Changes pattern.
**Step 1: enable Realtime for the table** (one-time setup, in SQL):
```sql theme={null}
-- Add public.todos to the supabase_realtime publication. If the publication
-- doesn't exist yet, create it.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN
CREATE PUBLICATION supabase_realtime;
END IF;
END $$;
ALTER PUBLICATION supabase_realtime ADD TABLE public.todos;
```
**Step 2: configure RLS on `public.todos`** so the channel only forwards the user's own rows. This is the same own-rows pattern from the [RLS Cookbook](/guides/rls-policies):
```sql theme={null}
ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;
CREATE POLICY own_todos_read ON public.todos
FOR SELECT TO authenticated
USING (owner_id = auth.uid());
-- (plus INSERT/UPDATE/DELETE policies — see the cookbook)
```
**Step 3: subscribe from the browser** using the signed-in user's access token. This is the part most apps spend time on.
```typescript theme={null}
const accessToken = localStorage.getItem("powabase_access_token");
const ws = connect(accessToken);
let myRef = 1;
const nextRef = () => String(myRef++);
ws.addEventListener("open", () => {
// Join the channel. Topic is arbitrary, but the convention is "realtime::]".
send(ws, nextRef(), "realtime:public:todos", "phx_join", {
config: {
postgres_changes: [
{
event: "*", // INSERT, UPDATE, DELETE, or "*" for all three
schema: "public",
table: "todos",
},
],
},
});
});
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.event === "phx_reply" && msg.payload.status === "ok") {
console.log("Joined channel:", msg.topic);
return;
}
if (msg.event === "postgres_changes") {
const { eventType, new: newRow, old: oldRow } = msg.payload.data;
switch (eventType) {
case "INSERT": addTodoToUI(newRow); break;
case "UPDATE": updateTodoInUI(newRow); break;
case "DELETE": removeTodoFromUI(oldRow.id); break;
}
}
});
// Heartbeat — Realtime expects one every 30s, kills the connection after 60s of silence
setInterval(() => {
send(ws, nextRef(), "phoenix", "heartbeat", {});
}, 30_000);
```
The `phx_join` frame opens the subscription. The `postgres_changes` config tells Realtime which events to forward. You can add a `filter` field for column equality (e.g., `filter: "user_id=eq."`), but here the RLS policy already restricts to the user's own rows, so an extra filter is redundant.
**Step 4: handle reconnects.** If the WebSocket drops mid-session, your client loses everything that happened during the gap. The pattern is to refetch on reconnect: assume the local state may be stale, reissue the original "load all todos" query, then resume the subscription. Realtime is not a durable queue.
## Recipe 2: Presence (who's online)
For a collaborative document where you show avatars of everyone currently viewing the page. Each user broadcasts their own presence state; everyone sees the union.
```typescript theme={null}
const ws = connect(accessToken);
const channelName = `presence:doc-${docId}`;
ws.addEventListener("open", () => {
// Join with a presence config — no postgres_changes here
send(ws, nextRef(), channelName, "phx_join", {
config: { presence: { key: userId } },
});
});
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.event === "phx_reply" && msg.payload.status === "ok" && msg.topic === channelName) {
// We're joined. Track our presence.
send(ws, nextRef(), channelName, "presence_diff", {
action: "track",
data: {
user_id: userId,
display_name: userDisplayName,
cursor_color: pickColor(userId),
joined_at: new Date().toISOString(),
},
});
return;
}
if (msg.event === "presence_state") {
// Initial snapshot of everyone currently in the channel
const everyone = msg.payload; // { user_id: [presence_data, ...] }
setOnlineUsers(Object.values(everyone).flat());
}
if (msg.event === "presence_diff") {
// Incremental updates
const { joins, leaves } = msg.payload;
handlePresenceDiff(joins, leaves);
}
});
// Untrack on tab close so others see us leave promptly
window.addEventListener("beforeunload", () => {
send(ws, nextRef(), channelName, "presence_diff", { action: "untrack" });
});
```
**Two things to know.** First, presence is **per-channel, not per-connection**: if a user opens two tabs, they appear twice in the presence list unless you dedupe by `user_id` client-side. Second, `untrack` only fires when the tab closes cleanly. If the user kills the tab or loses connectivity, Realtime infers the leave from a heartbeat timeout (about 60 seconds), so the avatar lingers briefly.
For "real-time cursor positions on top of a shared document," combine Presence (to know who's online) with Broadcast (to send the position updates without storing them). Each cursor move emits a broadcast event with `{ user_id, x, y }`; the receivers update each user's cursor based on the latest position they've seen.
## Recipe 3: Broadcast chat
A chat room where every message goes to every subscriber. No persistence — refresh the page and the history is gone. Real apps would also write messages to a table for history, but the broadcast pattern is the right starting point.
```typescript theme={null}
const ws = connect(accessToken);
const channelName = `chat:room-${roomId}`;
ws.addEventListener("open", () => {
send(ws, nextRef(), channelName, "phx_join", {
config: { broadcast: { self: false, ack: true } },
});
});
// Send a message
function sendMessage(text: string) {
send(ws, nextRef(), channelName, "broadcast", {
type: "broadcast",
event: "chat_message",
payload: { user_id: userId, display_name: userDisplayName, text, at: Date.now() },
});
}
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.event === "broadcast" && msg.payload.event === "chat_message") {
appendMessageToUI(msg.payload.payload);
}
});
```
**Two config options worth knowing.** `self: false` means the sender doesn't receive their own messages, usually what you want since you're already showing them the message you sent. `ack: true` means Realtime sends a `phx_reply` confirming the broadcast was delivered to its routing layer (it doesn't confirm individual subscribers received it).
For a private chat room (only authenticated users in the room can read messages), wrap the join in a private channel:
```typescript theme={null}
send(ws, nextRef(), channelName, "phx_join", {
config: {
broadcast: { self: false, ack: true },
private: true, // Realtime will check realtime.messages RLS
},
});
```
And define the RLS policy that gates who can join:
```sql theme={null}
CREATE POLICY only_room_members ON realtime.messages
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.room_members
WHERE user_id = auth.uid()
AND room_id::text = (string_to_array(realtime.topic(), ':'))[2]
)
);
```
`realtime.topic()` returns the channel name being checked; we split it on `:` and look up membership. The pattern is straight out of the [RLS Cookbook](/guides/rls-policies): Realtime is just one more table to write policies against.
## REST broadcast (sending from a backend)
For server-side message emission (webhook handlers, scheduled jobs), use the REST broadcast endpoint instead of opening a WebSocket from your backend:
```python Python theme={null}
import requests
requests.post(
f"https://{ref}.p.powabase.ai/realtime/v1/api/broadcast",
headers={
"apikey": SERVICE_ROLE_KEY,
"Authorization": f"Bearer {SERVICE_ROLE_KEY}",
"Content-Type": "application/json",
},
json={
"messages": [
{
"topic": "chat:room-42",
"event": "system_announcement",
"payload": {"text": "Server maintenance in 5 min"},
"private": False,
},
],
},
)
```
```typescript TypeScript theme={null}
await fetch(`${BASE_URL_HTTP}/realtime/v1/api/broadcast`, {
method: "POST",
headers: {
apikey: SERVICE_ROLE_KEY,
Authorization: `Bearer ${SERVICE_ROLE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: [
{ topic: "chat:room-42", event: "system_announcement", payload: { text: "Maintenance in 5 min" }, private: false },
],
}),
});
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/realtime/v1/api/broadcast' \
-H "apikey: " \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{"messages":[{"topic":"chat:room-42","event":"system_announcement","payload":{"text":"Maintenance in 5 min"},"private":false}]}'
```
The endpoint takes an array, so you can fan out to multiple channels in one call. Use the Service Role Key — only it can broadcast on behalf of the server.
## Common failure modes
* **`403 TenantNotFound` on WS upgrade.** Only happens with self-hosted Realtime where Kong isn't preserving the Host header. On Powabase managed cloud, you should never see this. If you do, file a support ticket.
* **`401` immediately after `phx_join` on a private channel.** Your RLS policy on `realtime.messages` denied the join. Test the policy in psql with the role+claims set, same as the [RLS Testing](/guides/rls-testing) flow.
* **Joined OK but no `postgres_changes` events arrive.** Either (a) the table isn't in the `supabase_realtime` publication, (b) RLS on the underlying table denies SELECT for your role, or (c) the filter syntax is wrong. Check the publication first: `SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime';`.
* **WebSocket closes after 60 seconds of silence.** You're not sending heartbeats. Add the `setInterval` from the recipes above.
* **Reconnect storms after a deploy.** Realtime pods restart during deploys; clients reconnect immediately and pile on. Add exponential backoff with jitter to your reconnect logic: start at 1s, double up to 30s, jitter ±25%.
## Next steps
The three channel types, auth, and the publication-setup gotcha.
WebSocket protocol details and the REST broadcast endpoint.
The patterns that gate private channels (via realtime.messages RLS) and underlying tables for Postgres Changes.
Why you can't use LISTEN/NOTIFY as a Realtime alternative through the pooler.
# RLS Cookbook
Source: https://docs.powabase.ai/guides/rls-policies
Five copy-paste policy patterns for common scenarios: own-rows-only, public-read with auth-write, tenant isolation, role-based access, soft-delete-aware.
This page is a recipe collection. Each pattern is a working SQL policy you can paste into the [Studio SQL editor](https://app.powabase.ai) or your migrations, with notes on when to use it, common variants, and the gotchas that bite people. For the conceptual underpinning, see [Row Level Security](/concepts/rls-model). For testing without a frontend, see [RLS Testing](/guides/rls-testing).
Every example here assumes:
* The table is in `public`, RLS is **enabled** (`ALTER TABLE ... ENABLE ROW LEVEL SECURITY`), and you want to add policies on top.
* You're signing in users through GoTrue, so `auth.uid()` returns the user's id.
* You want to expose the table directly to clients via PostgREST. (If you're only ever hitting it from a backend with the Service Role key, you don't need any of this; `service_role` bypasses RLS.)
**Step zero: enable RLS, then add policies.** `ALTER TABLE my_table ENABLE ROW LEVEL SECURITY;` flips the default to "deny all." If you then add no policies, the table is unreadable by anyone except `service_role`. Always pair the `ENABLE` with the policies in a single migration.
## Pattern 1: Each user sees and edits only their own rows
The most common pattern. A `todos` table where every row has an `owner_id` column, and users can only touch their own rows.
```sql theme={null}
CREATE TABLE public.todos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id),
title text NOT NULL,
done boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;
-- Read your own todos
CREATE POLICY own_todos_read ON public.todos
FOR SELECT TO authenticated
USING (owner_id = auth.uid());
-- Insert todos that you own
CREATE POLICY own_todos_insert ON public.todos
FOR INSERT TO authenticated
WITH CHECK (owner_id = auth.uid());
-- Update your own todos (and can't change owner_id to someone else)
CREATE POLICY own_todos_update ON public.todos
FOR UPDATE TO authenticated
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- Delete your own todos
CREATE POLICY own_todos_delete ON public.todos
FOR DELETE TO authenticated
USING (owner_id = auth.uid());
```
**Why the WITH CHECK on UPDATE.** `USING` decides which rows the policy applies to *before* the update; `WITH CHECK` validates the *result*. Without `WITH CHECK`, a user could change `owner_id` from their own id to anyone else's mid-update and the policy would still pass. Always pair `USING` + `WITH CHECK` on `UPDATE` policies.
**Variant: let the server set `owner_id`.** Instead of trusting the client to send `owner_id`, default it from the session:
```sql theme={null}
ALTER TABLE public.todos
ALTER COLUMN owner_id SET DEFAULT auth.uid();
```
Now `INSERT` requests that omit `owner_id` get the caller's id automatically, and your `WITH CHECK (owner_id = auth.uid())` ensures they can't override it.
## Pattern 2: Public read, auth-only write
A blog. Anyone, including unauthenticated visitors, can read posts. Only the author can create, edit, or delete their own posts.
```sql theme={null}
CREATE TABLE public.posts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
author_id uuid NOT NULL REFERENCES auth.users(id) DEFAULT auth.uid(),
slug text UNIQUE NOT NULL,
title text NOT NULL,
body text NOT NULL,
published boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
-- Everyone can read published posts
CREATE POLICY posts_public_read ON public.posts
FOR SELECT TO anon, authenticated
USING (published = true);
-- Authors can read their own posts even if unpublished (drafts)
CREATE POLICY posts_author_read_drafts ON public.posts
FOR SELECT TO authenticated
USING (author_id = auth.uid());
-- Authors can create, update, delete their own posts
CREATE POLICY posts_author_write ON public.posts
FOR INSERT TO authenticated
WITH CHECK (author_id = auth.uid());
CREATE POLICY posts_author_update ON public.posts
FOR UPDATE TO authenticated
USING (author_id = auth.uid())
WITH CHECK (author_id = auth.uid());
CREATE POLICY posts_author_delete ON public.posts
FOR DELETE TO authenticated
USING (author_id = auth.uid());
```
**Important:** RLS policies are **additive (OR-combined)** within the same role. Both the `posts_public_read` and `posts_author_read_drafts` policies apply when a signed-in user reads, so they see all published posts AND their own drafts. That's the desired behavior here.
## Pattern 3: Tenant isolation (multi-org SaaS)
You're building a SaaS where each user belongs to one or more organizations and rows are scoped per organization. A `documents` table where users only see documents in orgs they're a member of.
You need a `members` table that says who belongs to which org:
```sql theme={null}
CREATE TABLE public.members (
user_id uuid NOT NULL REFERENCES auth.users(id),
org_id uuid NOT NULL,
role text NOT NULL DEFAULT 'member', -- e.g. 'admin' | 'member'
PRIMARY KEY (user_id, org_id)
);
ALTER TABLE public.members ENABLE ROW LEVEL SECURITY;
CREATE POLICY members_read_own_memberships ON public.members
FOR SELECT TO authenticated
USING (user_id = auth.uid());
-- (Membership management is server-side; no INSERT/UPDATE/DELETE for users.)
```
Then the `documents` table policy uses a subquery against `members`:
```sql theme={null}
CREATE TABLE public.documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL,
title text NOT NULL,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
-- Read documents in any org you belong to
CREATE POLICY docs_member_read ON public.documents
FOR SELECT TO authenticated
USING (
org_id IN (
SELECT org_id FROM public.members WHERE user_id = auth.uid()
)
);
-- Write requires being in the org
CREATE POLICY docs_member_write ON public.documents
FOR INSERT TO authenticated
WITH CHECK (
org_id IN (
SELECT org_id FROM public.members WHERE user_id = auth.uid()
)
);
CREATE POLICY docs_member_update ON public.documents
FOR UPDATE TO authenticated
USING (
org_id IN (
SELECT org_id FROM public.members WHERE user_id = auth.uid()
)
)
WITH CHECK (
org_id IN (
SELECT org_id FROM public.members WHERE user_id = auth.uid()
)
);
```
**Performance gotcha.** That `IN (SELECT ...)` runs once per row scanned at worst. Add an index on `members(user_id, org_id)` and Postgres will turn it into a hash semi-join. If `documents` gets large, also index `documents(org_id)`. For very high cardinality, denormalize: stuff the user's allowed `org_ids` into the JWT (via a GoTrue hook) and read them from `auth.jwt() -> 'org_ids'` directly, which avoids the join entirely.
## Pattern 4: Role-based access (admin / member)
Extending the tenant pattern: only org admins can delete documents, members can read and create.
Two reasonable approaches.
**Option A: encode role in the policy expression.** Re-use the `members.role` column:
```sql theme={null}
CREATE POLICY docs_admin_delete ON public.documents
FOR DELETE TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.members
WHERE user_id = auth.uid()
AND org_id = documents.org_id
AND role = 'admin'
)
);
```
**Option B: encode role in the JWT.** If GoTrue is minting tokens with custom claims (e.g., via a Postgres function hook on sign-in), you can stash a `{"role": "admin"}` claim and check it directly:
```sql theme={null}
CREATE POLICY docs_admin_delete ON public.documents
FOR DELETE TO authenticated
USING (auth.jwt() ->> 'app_role' = 'admin');
```
Option A is the right default: roles are dynamic, change without re-signing the user in, and there's a single source of truth. Option B is faster (no subquery) but requires re-issuing tokens when roles change. Use it for things that genuinely won't change mid-session, like whether the user is verified at all.
## Pattern 5: Soft delete
Instead of physically deleting rows, you mark them with `deleted_at`. Active queries should hide them; admins should still see them. The trick is to filter `deleted_at IS NULL` in the policy itself, so callers never have to add it.
```sql theme={null}
ALTER TABLE public.posts ADD COLUMN deleted_at timestamptz;
CREATE INDEX posts_active_idx ON public.posts (deleted_at) WHERE deleted_at IS NULL;
-- Replace the read policies with deletion-aware versions
DROP POLICY posts_public_read ON public.posts;
DROP POLICY posts_author_read_drafts ON public.posts;
CREATE POLICY posts_active_public_read ON public.posts
FOR SELECT TO anon, authenticated
USING (deleted_at IS NULL AND published = true);
CREATE POLICY posts_active_author_read ON public.posts
FOR SELECT TO authenticated
USING (deleted_at IS NULL AND author_id = auth.uid());
-- "Delete" is now an UPDATE that sets deleted_at
DROP POLICY posts_author_delete ON public.posts;
CREATE POLICY posts_author_soft_delete ON public.posts
FOR UPDATE TO authenticated
USING (author_id = auth.uid())
WITH CHECK (author_id = auth.uid());
```
Callers now issue `PATCH /rest/v1/posts?id=eq.{id}` with `{"deleted_at": ""}` instead of `DELETE`. The policies hide the row from subsequent reads.
**Variant: let admins see deleted rows.** Add a separate policy for the admin role:
```sql theme={null}
CREATE POLICY posts_admin_read_all ON public.posts
FOR SELECT TO authenticated
USING (auth.jwt() ->> 'app_role' = 'admin');
```
Because policies OR-combine, admins see both active rows (via the regular policy) AND soft-deleted ones (via this one). Regular users still only see active rows.
## Patterns worth knowing about
A few that come up but don't need full recipes:
* **Force RLS for the table owner.** By default the table owner (`service_role` and the project Postgres user) bypasses RLS. To make RLS apply even to the owner (useful for safety in shared environments), use `ALTER TABLE ... FORCE ROW LEVEL SECURITY`. Don't use this on the `ai.*` tables; the platform's backend assumes service-role bypass.
* **Permissive vs restrictive policies.** All policies are `PERMISSIVE` by default, so they OR together. `RESTRICTIVE` policies AND together with the result. Useful when you want to layer a "no row may be deleted on Sundays" check on top of existing permissive policies without rewriting them.
* **Functions in policy expressions.** Postgres caches policy expression results per row per query. A `SELECT 1 FROM members WHERE ...` subquery is fine; an HTTP call from inside a policy (via `pg_net`) is not, and it'll run thousands of times. Keep policy expressions cheap and deterministic.
## Next steps
Test policies in the SQL Editor or psql before deploying.
How JWTs, roles, and auth.uid() compose under the hood.
The default RLS posture on ai.\* and when to tighten it.
PostgREST patterns for analytics, bulk ops, and embeds.
# RLS Testing
Source: https://docs.powabase.ai/guides/rls-testing
Verify RLS policies in psql or the Studio SQL Editor without spinning up a frontend, by impersonating the anon and authenticated roles with hand-crafted JWT claims.
You wrote a policy. Before you ship it to a thousand users, you want to confirm it does what you think it does. The trick is that PostgREST sets the role and JWT claims for you on real requests. In a SQL session you have to do that setup by hand.
This guide shows how to impersonate `anon` and `authenticated` (with any `auth.uid()` you choose) from `psql` or the Studio SQL Editor, then run your reads and writes against the policy to confirm it accepts the right things and rejects the wrong things. For the policies themselves, see the [RLS Cookbook](/guides/rls-policies). For the model that determines who gets which role, see [RLS Model](/concepts/rls-model).
## How PostgREST sets the session
When PostgREST receives a request, it does roughly this on the connection before running your query:
```sql theme={null}
SET LOCAL ROLE authenticated; -- or anon, or service_role
SET LOCAL request.jwt.claims = '{"sub":"","role":"authenticated","email":"u@x"}';
```
To mimic that in your own session, run the same two statements. `SET LOCAL` scopes the change to the current transaction, so wrap your testing in a `BEGIN ... ROLLBACK` block and you can iterate without polluting other connections.
## Test as the anon role
```sql theme={null}
BEGIN;
SET LOCAL ROLE anon;
-- anon has no JWT claims; auth.uid() returns NULL.
-- Try the read you expect anon to be allowed:
SELECT count(*) FROM public.posts WHERE published = true;
-- Expected: returns counts.
-- Try the read you expect anon to be denied:
SELECT count(*) FROM public.posts WHERE published = false;
-- Expected: returns 0 (RLS hides unpublished posts even from a count).
-- Try a write you expect anon to be denied:
INSERT INTO public.posts (title, body) VALUES ('hi', 'oh no');
-- Expected: ERROR: new row violates row-level security policy
ROLLBACK;
```
The `ROLLBACK` undoes the `SET LOCAL ROLE` and any rows you happened to insert that the policy let through. Use it religiously while iterating. Without it, you'll accidentally leave the session in an unexpected role and the next query will mislead you.
## Test as a specific signed-in user
```sql theme={null}
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims =
'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}';
-- Now auth.uid() returns the uuid above.
SELECT auth.uid();
-- auth.uid
-- --------------------------------------
-- 11111111-1111-1111-1111-111111111111
-- Try the user's own-row read:
SELECT * FROM public.todos WHERE owner_id = auth.uid();
-- Expected: their rows.
-- Try a write that should succeed:
INSERT INTO public.todos (owner_id, title)
VALUES (auth.uid(), 'test')
RETURNING *;
-- Expected: row inserted.
-- Try a write that should fail (assigning to another user):
INSERT INTO public.todos (owner_id, title)
VALUES ('22222222-2222-2222-2222-222222222222', 'evil');
-- Expected: ERROR: new row violates row-level security policy
ROLLBACK;
```
## Test custom claims (for `auth.jwt()` policies)
If you wrote a policy that reads `auth.jwt() ->> 'app_role'`, you set that claim in the same JSON blob:
```sql theme={null}
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '{
"sub":"11111111-1111-1111-1111-111111111111",
"role":"authenticated",
"app_role":"admin"
}';
DELETE FROM public.documents WHERE id = 'some-uuid';
-- Should succeed if your policy checks app_role = 'admin'.
ROLLBACK;
```
For a non-admin claim, switch the value:
```sql theme={null}
SET LOCAL request.jwt.claims = '{
"sub":"11111111-1111-1111-1111-111111111111",
"role":"authenticated",
"app_role":"member"
}';
DELETE FROM public.documents WHERE id = 'some-uuid';
-- Should fail (or no rows deleted if the WHERE clause matches but RLS denies).
```
## Test as service\_role
`service_role` bypasses RLS, so testing as it confirms the row exists at all (independent of policies):
```sql theme={null}
BEGIN;
SET LOCAL ROLE service_role;
SELECT * FROM public.todos WHERE id = 'some-uuid';
-- Returns the row regardless of who owns it.
ROLLBACK;
```
If `service_role` returns a row but `authenticated` doesn't, the policy is doing its job. If `service_role` doesn't return it either, the row doesn't exist, and you have a different bug.
## A small harness for iteration
Put this in a SQL file and re-run it as you tweak policies:
```sql theme={null}
\set ON_ERROR_STOP on
-- Setup: create test users (run once)
INSERT INTO auth.users (id, email)
VALUES ('11111111-1111-1111-1111-111111111111', 'alice@example.com')
ON CONFLICT (id) DO NOTHING;
INSERT INTO auth.users (id, email)
VALUES ('22222222-2222-2222-2222-222222222222', 'bob@example.com')
ON CONFLICT (id) DO NOTHING;
-- Test as alice
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims =
'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}';
-- ...assertions...
ROLLBACK;
-- Test as bob
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims =
'{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}';
-- ...assertions...
ROLLBACK;
```
In the Studio SQL Editor, run each block as a separate snippet. The editor doesn't preserve session state across "Run" presses, so you re-`SET LOCAL` every time.
## Common things that aren't what they seem
* **Querying `auth.users` directly** returns rows because the `auth.users` table is owned by `supabase_auth_admin`, not `authenticated`. Don't be fooled: policies on tables you create *do* apply.
* **`RAISE NOTICE 'auth.uid is %', auth.uid();`** doesn't fail if the role/claims aren't set; it just prints `NULL`. If you're getting unexpected RLS denials, add a `SELECT auth.uid(), auth.role();` at the top of your test block to confirm what the session thinks it is.
* **`SET ROLE` (without `LOCAL`) persists across the session.** If you accidentally use it and later wonder why your policies are denying everything, run `RESET ROLE` and you'll be back to the connecting user.
* **The Studio SQL Editor runs as `postgres`** (the project owner) by default. Without `SET LOCAL ROLE`, RLS is bypassed entirely and every query looks like it works. Always `SET LOCAL ROLE` first.
## Next steps
The five core patterns this guide is for testing.
Roles, JWT claims, and how PostgREST sets them on real requests.
# Self-host (enterprise)
Source: https://docs.powabase.ai/guides/self-host-enterprise
Self-hosting Powabase is an enterprise offering: what gets deployed, how it differs from managed cloud, and how to engage. Not a publicly self-serve product.
Powabase is available for self-hosting as part of the enterprise offering. It's not a publicly self-serve product. The deployment is Kubernetes-based, the operations posture is "you run it, we support you," and you engage directly with the platform team rather than through docs-driven self-service.
This page positions the option and pre-answers common questions; it does not ship the deploy runbook. If you're considering self-host, the next step is a conversation with sales, who will route you to the platform team for the deploy walkthrough.
## Who self-host is for
A few profiles that map to "self-host is the right answer":
* **Regulated industries** where data residency in customer-controlled infrastructure is non-negotiable.
* **Air-gapped or restricted networks** where data can't leave the customer's perimeter.
* **High-volume workloads** where per-project credit-based billing doesn't align with the org's procurement model.
* **Customers who want longer backup retention windows or point-in-time recovery** beyond managed cloud defaults.
* **Customers building Powabase-aware tooling** that needs deeper integration than the API surface provides.
For most teams building applications on Powabase, **managed cloud is the right answer**. The operational overhead of self-hosting Kubernetes infrastructure typically outweighs the control benefits. Self-host is for organizations that have a specific reason and the platform-engineering capacity to operate the cluster.
## What gets deployed
The Powabase self-host stack is a Kubernetes deployment, packaged as a set of Helm charts:
* **`project-stack`**: the per-project resources (one Postgres StatefulSet, one GoTrue deployment, one Storage API deployment, one Realtime deployment, one project-api deployment, the Kong gateway). Provisioned once per project in its own namespace.
* **`shared-services`**: the cluster-wide resources (PgBouncer, imgproxy, the wildcard ALB ingress, monitoring stack). Provisioned once per cluster.
* **`control-plane`**: the management tier (the Studio backend, project provisioner, billing service connection, GoTrue for the platform's own users). Provisioned once per deployment.
The stack runs on AWS today (EKS) but the Helm charts are not AWS-specific. The dependencies are standard Kubernetes primitives plus AWS Load Balancer Controller for the wildcard ingress. Customers running on GCP / Azure / on-prem have shipped self-host deployments with chart adaptations.
## What differs from managed cloud
Self-host inherits the same code as managed cloud: the same versions of GoTrue, PostgREST, Storage, Realtime, and the agentic services. Operationally, several things differ:
* **You control backups.** The platform's daily `pg_dump` cronjob is still in the chart, but you point it at your own S3 (or equivalent object storage). Retention, schedule, and restore process are all yours.
* **You control monitoring.** The Prometheus metrics that managed cloud doesn't expose externally are fully available in self-host. Point your existing observability stack (Grafana, Datadog, etc.) at them.
* **You can tighten Kong CORS.** Managed cloud sets `origins: ["*"]` because it's serving a multi-tenant Studio. Self-host can lock CORS down per-project as needed.
* **You control which extensions are available.** The Postgres image is yours; if you want `pg_cron` or `postgis` enabled, do it.
* **Billing isn't part of the deployment.** Credit-based billing is a managed-cloud feature; self-host runs without it. The 402 / 503 paths in the project-service short-circuit when no billing service is configured.
## What stays the same
* **Same APIs.** All the `/api/*`, `/rest/v1/*`, `/auth/v1/*`, `/storage/v1/*`, `/realtime/v1/*` paths work identically.
* **Same SQL.** PostgreSQL with the same extensions (vector, pg\_net, pgcrypto, uuid-ossp, pg\_graphql, vault) preloaded the same way.
* **Same client code.** Your application doesn't change between managed and self-hosted; only the project URL differs.
This is the value of self-host: the same product, deployed somewhere you control.
## What's expected of you
Self-hosting is real infrastructure work. The platform-engineering team operating the deployment needs to handle:
* **Kubernetes cluster operations.** Node scaling, version upgrades, certificate rotation, network policy.
* **Database operations.** Postgres backup configuration, monitoring, version upgrades within the supabase/postgres image line.
* **Identity provider integration.** SSO via SAML/OIDC against your IdP if you want SSO-protected access to the Studio.
* **Secret management.** Provider keys, signing keys, database passwords need to live somewhere (AWS Secrets Manager, Vault, etc.) and feed into Helm via External Secrets or equivalent.
* **Capacity planning.** Per-project StatefulSet sizing, PgBouncer connection budgets, ALB scaling.
The platform team provides the Helm charts, the runbook, and ongoing support, but the customer's platform team is the one actually running the cluster.
## How to engage
The right starting move is a conversation with sales. They'll qualify whether self-host is the right answer for your case, then connect you with the platform team for the deploy walkthrough.
After the initial deploy, ongoing operational support is part of the enterprise contract: software updates, incident response, and platform expertise for non-obvious situations.
## Open-source / community edition
There is not currently a publicly-available self-serve self-host. The Helm charts and platform code aren't open-sourced. If that's a hard requirement for your use case, raise it during sales; the platform's posture here may evolve.
## Next steps
The infrastructure shape that self-host deploys.
Self-host customers get more flexibility here.
What's user-callable in managed cloud vs what's wide-open in self-host.
The full surface that self-host preserves.
# Studio SQL Editor
Source: https://docs.powabase.ai/guides/sql-editor
Run ad-hoc SQL in the Studio against your project's Postgres: what it's good for, what it's not, the role it runs as, and how to save snippets.
The Studio at [app.powabase.ai](https://app.powabase.ai) includes a SQL Editor that lets you run queries against your project's Postgres without spinning up a `psql` session. It connects through the same pooler URL the Connect modal shows, so the same transaction-mode constraints apply.
For the conceptual model behind direct SQL, see [Direct Postgres patterns](/guides/direct-postgres). For migrations specifically, see [Migrations](/guides/migrations).
## Where it is
In the Studio, under your project: **SQL Editor** in the left sidebar. You'll see a query pane, a results pane, and a list of saved snippets.
## What it's good for
* **One-off queries** during development: "what rows does this query return?", "did my migration actually apply?"
* **Inspecting schema.** `\d` doesn't work but `SELECT * FROM information_schema.tables WHERE table_schema = 'public'` does.
* **Manual data fixes:** patching a few rows after a migration bug, removing test data.
* **Testing RLS policies** before deploying. Combine with the `SET LOCAL ROLE` pattern from [RLS Testing](/guides/rls-testing) to simulate a specific role.
* **Saved snippets.** Frequently-used queries (usage reports, debugging selectors) tucked into the sidebar so you don't retype them.
## What it's not for
* **Long-running migrations.** The editor times out at the pooler-level statement timeout (30 seconds on the `authenticator` role; higher as `supabase_admin` but still bounded). For long migrations, use a `.sql` file with `psql` from your laptop or CI.
* **Production hot-fix workflow.** Running ad-hoc SQL in production is how mistakes get made. Use it for inspection; gate writes behind your normal migration flow.
* **Application traffic.** This is a Studio-internal tool, not an HTTP API. Don't try to drive it programmatically; it has no documented endpoint contract.
## The role you run as
The SQL Editor runs queries as the project's `postgres` superuser by default, which means **RLS is bypassed for editor queries**. This is why "I tested it in the SQL editor and it worked, but my app gets denied" trips people up: your app connects as `anon` or `authenticated`, and RLS applies there.
To test as `anon` or `authenticated`, wrap your test in a transaction and `SET LOCAL ROLE`:
```sql theme={null}
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims =
'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}'::jsonb;
SELECT * FROM public.posts; -- This now applies your RLS policies
ROLLBACK;
```
The `ROLLBACK` undoes the `SET LOCAL` changes. This is the same pattern as the [RLS Testing](/guides/rls-testing) recipes; the editor is just a different surface for running them.
## Snippets
Click "New snippet" (or save the current query) and the Studio persists it under your account. Snippets are scoped per user; your team won't see them unless you share the snippet URL.
A few snippets worth saving for any project:
```sql theme={null}
-- Active sessions on your Postgres right now
SELECT pid, usename, query, state, query_start
FROM pg_stat_activity
WHERE state != 'idle'
AND backend_type = 'client backend'
ORDER BY query_start;
-- Table sizes (your largest tables first)
SELECT schemaname, relname, pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE schemaname IN ('public', 'ai')
ORDER BY pg_relation_size(relid) DESC
LIMIT 20;
-- Indexes on a table
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'your_table';
```
## Next steps
psql equivalents for everything the editor does, plus things it can't.
The SET LOCAL ROLE pattern in detail.
The five schemas you'll see in pg\_namespace and what each contains.
Move from one-off editor queries to versioned migrations.
# Storage policies
Source: https://docs.powabase.ai/guides/storage-policies
Three copy-paste RLS patterns for storage.objects: own-files-only, public-read with auth-write, role-based access. Plus the storage.foldername helper and the pitfalls that bite people.
The `storage.objects` table is a regular Postgres table with Row Level Security enabled. Every file operation through `/storage/v1/object/authenticated/*` runs RLS policies on this table before letting the request through, just like queries against your own `public.*` tables.
This page is a recipe collection for the three storage-specific patterns that come up most. For the conceptual model, see [Storage model](/concepts/storage-model). For the general RLS framing, see [RLS Model](/concepts/rls-model). For uploads, see [Storage uploads](/guides/storage-uploads).
## The shape of storage.objects
Before the patterns, what you're writing policies against:
```sql theme={null}
storage.objects (
id uuid PRIMARY KEY,
bucket_id text NOT NULL REFERENCES storage.buckets(id),
name text, -- the full path within the bucket, slashes included
owner uuid, -- set to auth.uid() on insert, NULL for anon uploads
created_at timestamptz,
updated_at timestamptz,
last_accessed_at timestamptz,
metadata jsonb -- size, mimetype, content-encoding, etc.
)
```
Two functions you'll see in policies:
* **`storage.foldername(name)`** splits the path by `/` and returns the segments as a `text[]`. So for an object at `documents/2026/q1/report.pdf`, `storage.foldername` returns `{documents, 2026, q1}` (excluding the filename).
* **`storage.filename(name)`** returns just the filename part. For the same path, returns `report.pdf`.
You combine these with `bucket_id` and `auth.uid()` to express "files in this bucket, in this folder, owned by this user."
## Pattern 1: Users access only their own files
Most common pattern. Files are keyed by user id as the first path segment (e.g., `avatars//photo.png`). Each user can upload, read, update, and delete only their own files.
```sql theme={null}
-- Upload: only allow paths starting with the user's own id
CREATE POLICY upload_own_files ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Read: only the user's own files
CREATE POLICY read_own_files ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Update (re-upload, change metadata): same constraint
CREATE POLICY update_own_files ON storage.objects
FOR UPDATE TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
)
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Delete
CREATE POLICY delete_own_files ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
```
**Cast matters.** `auth.uid()` returns `uuid`; `storage.foldername(name)[1]` returns `text`. The cast `auth.uid()::text` makes the comparison work. Without it, Postgres errors with "operator does not exist."
**Why path-prefix not owner?** You *could* match on `owner = auth.uid()` instead. The difference is what happens when a user uploads to a path that doesn't start with their id. With `owner = auth.uid()`, the owner is set at upload time, so the policy passes by construction; `(storage.foldername(name))[1] = auth.uid()::text` blocks it because the path itself is wrong. Path-prefix is stricter: it prevents a user from uploading to `/file.png` even if they somehow get the `owner` column set to their own id.
For belt-and-suspenders, check both:
```sql theme={null}
CREATE POLICY upload_own_files ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
AND owner = auth.uid()
);
```
## Pattern 2: Public read, auth-only write
Think of the bucket as a content site: anyone can read any file (the bucket itself is `public = true`, so the read side is free, see the warning below), but only signed-in users can upload, and only to paths they own.
```sql theme={null}
-- Anyone can read (handled by the bucket's public flag — no policy needed
-- because the public-bucket URL bypasses RLS entirely).
-- Authenticated users can upload to their own folder
CREATE POLICY upload_own_in_public ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'public-blog-images'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Authenticated users can update their own uploads
CREATE POLICY update_own_in_public ON storage.objects
FOR UPDATE TO authenticated
USING (
bucket_id = 'public-blog-images'
AND (storage.foldername(name))[1] = auth.uid()::text
)
WITH CHECK (
bucket_id = 'public-blog-images'
AND (storage.foldername(name))[1] = auth.uid()::text
);
-- Authenticated users can delete their own uploads
CREATE POLICY delete_own_in_public ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'public-blog-images'
AND (storage.foldername(name))[1] = auth.uid()::text
);
```
**`public = true` on a bucket means the public URL bypasses RLS entirely.** Anyone fetching `GET /storage/v1/object/public/{bucket}/{path}` gets the file without any policy check. The RLS policies above only gate the *authenticated* URL (`/storage/v1/object/authenticated/`) and the metadata operations on `storage.objects`. If you don't want some files publicly fetchable, **don't put them in a public bucket.** Use a private bucket and signed URLs instead.
## Pattern 3: Role-based access (admins manage, users read their own)
A team document-sharing app: every user can read and upload their own files (Pattern 1), but admins can read and delete anyone's files.
Using the JWT claim approach (admin is set on `app_metadata.role` via `PUT /auth/v1/admin/users/{id}`):
```sql theme={null}
-- Existing per-user policies from Pattern 1 still apply.
-- Add admin overrides on top — RLS policies are OR-combined for the same role.
CREATE POLICY admin_read_all ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'documents'
AND auth.jwt() -> 'app_metadata' ->> 'role' = 'admin'
);
CREATE POLICY admin_delete_all ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'documents'
AND auth.jwt() -> 'app_metadata' ->> 'role' = 'admin'
);
```
A user with `app_metadata.role = "admin"` matches both the per-user policy (for their own files) and the admin policy (for everyone's). Postgres ORs them; the admin sees everything in the bucket.
Using a database table (`public.team_members(user_id, role)`) instead, useful when roles change dynamically without re-issuing JWTs:
```sql theme={null}
CREATE POLICY admin_read_all ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'documents'
AND EXISTS (
SELECT 1 FROM public.team_members
WHERE user_id = auth.uid() AND role = 'admin'
)
);
```
Trade-offs are the same as the [RLS Cookbook's role-based pattern](/guides/rls-policies): JWT claims are faster but stale, database lookups are fresh but cost a join.
## Listing files in a bucket
The Storage API's `POST /storage/v1/object/list/{bucket}` endpoint runs against `storage.objects` and applies SELECT policies. Users see only the files they have a SELECT policy for. If you want to surface a "browse all files in this bucket" UI for admins, the admin policy above is what makes it work.
`GET /storage/v1/bucket/{id}` (list bucket metadata) is gated separately by policies on `storage.buckets`. By default, signed-in users see all bucket metadata in their project. To hide bucket existence from non-admins, add policies on `storage.buckets`:
```sql theme={null}
CREATE POLICY admin_only_bucket_visibility ON storage.buckets
FOR SELECT TO authenticated
USING (auth.jwt() -> 'app_metadata' ->> 'role' = 'admin');
```
(Most apps don't bother; bucket names aren't sensitive.)
## Folder structure as authorization
If your bucket is organized as `//`, you can express "users in org X can read anything in their org's folder" with a multi-segment check:
```sql theme={null}
CREATE POLICY read_org_files ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'org-shared'
AND (storage.foldername(name))[1] IN (
SELECT org_id::text FROM public.members WHERE user_id = auth.uid()
)
);
```
This is the multi-tenant pattern from the RLS Cookbook applied to Storage. The first folder segment is the org id; the policy checks that the calling user is a member of that org.
## Testing storage policies
The same techniques from [RLS Testing](/guides/rls-testing) apply: `SET LOCAL ROLE authenticated; SET LOCAL request.jwt.claims = '{"sub": "...", "role": "authenticated"}';` and then run your INSERT/SELECT against `storage.objects` to confirm the policy accepts the right things and rejects the wrong things.
A small detail: when testing storage policies, the path you pass to `storage.foldername()` matters. To simulate an upload at `avatars//photo.png`, your test row's `name` column should be that exact string. The policy reads `name`, not `path` or any other column.
## Pitfalls
* **Forgetting to enable RLS.** `storage.objects` has RLS enabled by default. If you're testing locally with a fresh schema and policies aren't applying, check that `ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY;` ran. Without RLS, every signed-in user sees everything.
* **The public URL bypasses RLS.** Worth repeating because it confuses people. If your bucket is `public = true`, no amount of policy work on `storage.objects` will gate `GET /storage/v1/object/public/...`. Use private buckets with signed URLs if you need RLS-gated reads.
* **`storage.foldername()` is zero-indexed in some examples online but one-indexed here.** PostgreSQL arrays are one-indexed: `(storage.foldername('a/b/c.png'))[1]` is `'a'`, not `'b'`. If you copy-paste from older Supabase docs, double-check the index.
* **Update policies need both USING and WITH CHECK.** Without `WITH CHECK`, a user could change `name` (or `owner`) to a path they don't own mid-update. Always pair them.
* **`auth.uid()` returns NULL for `anon`.** A policy like `owner = auth.uid()` never matches for anon, which is usually what you want. It does mean an anon-public-write bucket needs a separate policy targeting `TO anon` with whatever conditions you want.
## Next steps
The upload flows these policies gate.
Buckets, public vs private, and the underlying tables.
The general RLS patterns this page builds on.
How to test storage policies in psql or the Studio.
# Storage uploads
Source: https://docs.powabase.ai/guides/storage-uploads
Upload files to Powabase Storage: simple multipart, browser-direct with the Anon Key, server-side signed upload URLs, and TUS resumable uploads for large files.
There are four ways files get into Storage, depending on where the upload originates and how big the file is. This guide walks each one with the headers, body shape, and gotchas. For the conceptual model, see [Storage model](/concepts/storage-model). For the full API surface, see [Storage Reference](/api-reference/storage). To control who can upload what, see [Storage policies](/guides/storage-policies).
## Decision tree
| Scenario | Use |
| --------------------------------------------------- | ---------------------------------------------------------------- |
| Server-side upload (Node, Python backend, etc.) | Simple POST with the Service Role Key |
| Browser uploads under 50MB, public bucket | Direct POST with the Anon Key |
| Browser uploads under 50MB, private bucket | Same — RLS on `storage.objects` gates it |
| Files over 50MB | TUS resumable at `/storage/v1/upload/resumable` |
| Untrusted client, bandwidth on your backend matters | Signed upload URL (server mints, client uploads to S3-style URL) |
## Simple upload from a backend
The most basic flow. Server has the Service Role Key, picks a path, sends the bytes.
```python Python theme={null}
import requests
BASE_URL = "https://{ref}.p.powabase.ai"
SERVICE_ROLE_KEY = ""
with open("report.pdf", "rb") as f:
response = requests.post(
f"{BASE_URL}/storage/v1/object/documents/2026/q1/report.pdf",
headers={
"apikey": SERVICE_ROLE_KEY,
"Authorization": f"Bearer {SERVICE_ROLE_KEY}",
"Content-Type": "application/pdf",
},
data=f,
)
result = response.json()
# {"Id": "...", "Key": "documents/2026/q1/report.pdf"}
```
```typescript TypeScript theme={null}
const BASE_URL = "https://{ref}.p.powabase.ai";
const SERVICE_ROLE_KEY = "";
const fileBlob = await fs.readFile("./report.pdf");
const response = await fetch(
`${BASE_URL}/storage/v1/object/documents/2026/q1/report.pdf`,
{
method: "POST",
headers: {
apikey: SERVICE_ROLE_KEY,
Authorization: `Bearer ${SERVICE_ROLE_KEY}`,
"Content-Type": "application/pdf",
},
body: fileBlob,
},
);
const { Id, Key } = await response.json();
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/storage/v1/object/documents/2026/q1/report.pdf' \
-H "apikey: " \
-H "Authorization: Bearer " \
-H "Content-Type: application/pdf" \
--data-binary @report.pdf
```
**Key things:**
* The URL is `/storage/v1/object/{bucket}/{path}`. The bucket must already exist (`POST /storage/v1/bucket` if not).
* `Content-Type` is what the bucket's MIME allowlist (if any) checks against.
* Use `POST` for new objects, `PUT` to overwrite an existing object at the same path. `POST` to an existing path returns `409 Duplicate`.
* The path can include slashes. They're stored verbatim and let you organize "folders" client-side.
## Browser-direct upload (with the Anon Key)
For uploads from a signed-in user's browser, you don't want to proxy the file through your backend; that doubles your bandwidth and adds latency. Have the browser upload directly to Storage with the Anon Key plus the user's access token. RLS on `storage.objects` decides whether the upload is allowed.
```typescript TypeScript theme={null}
const ANON_KEY = "";
const accessToken = localStorage.getItem("powabase_access_token");
async function uploadAvatar(file: File) {
const path = `${userId}/${file.name}`;
const res = await fetch(
`${BASE_URL}/storage/v1/object/avatars/${path}`,
{
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": file.type,
},
body: file,
},
);
if (!res.ok) {
const err = await res.json();
throw new Error(err.message ?? res.statusText);
}
return res.json();
}
```
```python Python theme={null}
# Python is typically server-side. If you're uploading on behalf of an
# end user from a Python backend, the Service Role pattern above is
# usually what you want. To explicitly act as a specific user (so RLS
# applies, owner is set correctly, etc.), use the user's access token:
response = requests.post(
f"{BASE_URL}/storage/v1/object/avatars/{user_id}/photo.jpg",
headers={
"apikey": ANON_KEY,
"Authorization": f"Bearer {user_access_token}",
"Content-Type": "image/jpeg",
},
data=image_bytes,
)
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/storage/v1/object/avatars/user-123/photo.jpg' \
-H "apikey: " \
-H "Authorization: Bearer " \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg
```
**RLS policy you'll need on `storage.objects` for this to work** (assuming a bucket called `avatars` and a path convention of `/...`):
```sql theme={null}
CREATE POLICY upload_own_avatar ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
```
`storage.foldername()` is a helper that splits the path by `/` and returns the segments; `(storage.foldername(name))[1]` is the first segment. The policy above lets users upload only to paths starting with their own user id. For more patterns, see [Storage policies](/guides/storage-policies).
## Signed upload URLs
For high-bandwidth cases (large files, many concurrent uploaders) you might not want every upload to go through the Storage API. Instead, have your server mint a signed URL that lets the client upload directly to S3 (well, to Storage at a URL that Storage forwards to S3 without re-auth'ing). This is a two-step flow:
**Step 1 (server): mint the signed URL.**
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/storage/v1/object/upload/sign/documents/{user_id}/{filename}",
headers={
"apikey": SERVICE_ROLE_KEY,
"Authorization": f"Bearer {SERVICE_ROLE_KEY}",
},
)
result = response.json()
upload_url = f"{BASE_URL}{result['url']}" # path-relative URL — prepend BASE_URL
# Pass upload_url to your client.
```
```typescript TypeScript theme={null}
// Server-side
const res = await fetch(
`${BASE_URL}/storage/v1/object/upload/sign/documents/${userId}/${filename}`,
{
method: "POST",
headers: {
apikey: SERVICE_ROLE_KEY,
Authorization: `Bearer ${SERVICE_ROLE_KEY}`,
},
},
);
const { url } = await res.json();
const uploadUrl = `${BASE_URL}${url}`;
// Return uploadUrl to the browser.
```
```bash cURL theme={null}
curl -X POST 'https://{ref}.p.powabase.ai/storage/v1/object/upload/sign/documents/user-123/big-file.pdf' \
-H "apikey: " \
-H "Authorization: Bearer "
```
The response is `{ "url": "/object/upload/sign/documents/user-123/big-file.pdf?token=..." }`. The token in the URL is single-use and TTL-bounded (default 2 hours, configurable per request via `?expires_in=`).
**Step 2 (client): PUT the file to the signed URL.**
```typescript theme={null}
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
```
Notice the client doesn't send `Authorization` or `apikey`; the signature in the URL is the auth. This lets you decouple the upload from your auth layer (e.g., for offline clients that need to upload when reconnected).
## TUS resumable uploads (for files larger than 50MB)
For uploads that might fail mid-stream (large files on flaky connections, mobile users, video editors) use the TUS protocol at `/storage/v1/upload/resumable`. TUS chunks the upload, persists progress server-side, and lets the client pick up where it left off after a disconnect.
The protocol itself is involved (PATCH requests with offset headers, HEAD to query progress, etc.). Use a TUS client library rather than implementing it by hand:
```typescript TypeScript theme={null}
import * as tus from "tus-js-client";
const upload = new tus.Upload(file, {
endpoint: `${BASE_URL}/storage/v1/upload/resumable`,
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"x-upsert": "true", // overwrite if exists, otherwise 409
},
uploadDataDuringCreation: true,
removeFingerprintOnSuccess: true,
metadata: {
bucketName: "videos",
objectName: `${userId}/${file.name}`,
contentType: file.type,
},
chunkSize: 6 * 1024 * 1024, // 6MB chunks — under the 50MB API limit, big enough for throughput
onError: (err) => console.error("Upload failed", err),
onProgress: (bytesUploaded, bytesTotal) => {
const pct = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
console.log(`${pct}% — ${bytesUploaded}/${bytesTotal}`);
},
onSuccess: () => console.log("Upload complete:", upload.url),
});
// Check for previous uploads in progress (after a page reload, etc.)
const previousUploads = await upload.findPreviousUploads();
if (previousUploads.length > 0) {
upload.resumeFromPreviousUpload(previousUploads[0]);
}
upload.start();
```
```python Python theme={null}
# tuspy or similar TUS clients for Python — but Python uploads are
# typically server-side where the simple POST flow above is enough.
# For Python TUS, see the tuspy library docs.
```
```bash cURL theme={null}
# TUS is multi-request. Doing it by hand in cURL is impractical.
# Use a TUS client library or the upstream Supabase JS client.
```
**Chunk size:** keep it under 50MB (the per-request limit). 6MB is a common starting point: small enough that one failed chunk is fast to retry, big enough that you're not constantly thrashing on TUS overhead.
**Headers:** TUS needs the auth headers (`apikey` + `Authorization`) on every chunk request. The TUS client library handles this.
**Metadata:** the `bucketName`, `objectName`, and `contentType` fields in `metadata` tell Storage where to put the file. The TUS library base64-encodes them into the `Upload-Metadata` header.
## Downloading
For reads, use one of three URL shapes depending on the bucket's visibility and your auth model:
```
GET /storage/v1/object/public/{bucket}/{path} # no auth, public bucket only
GET /storage/v1/object/authenticated/{bucket}/{path} # user access token
GET /storage/v1/object/sign/{bucket}/{path}?token=... # signed URL (see below)
```
**Pre-signed download URLs.** For private files you want to share via email/link without making the bucket public:
```typescript theme={null}
// Server-side
const res = await fetch(
`${BASE_URL}/storage/v1/object/sign/documents/${path}`,
{
method: "POST",
headers: {
apikey: SERVICE_ROLE_KEY,
Authorization: `Bearer ${SERVICE_ROLE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ expiresIn: 3600 }), // seconds; default 60
},
);
const { signedURL } = await res.json();
const downloadUrl = `${BASE_URL}${signedURL}`;
// Share downloadUrl — anyone with it can fetch the file for the next hour.
```
The signed URL works without auth (the token in the query string is the credential). Useful for "send the customer a download link they can click in their email" patterns.
## Common failure modes
* **`413 Payload Too Large`:** the file (or chunk in TUS) exceeds 50MB. Use TUS with a smaller chunk size.
* **`409 Duplicate`:** POSTing to a path that already exists. Use `PUT` to overwrite or set the `x-upsert: true` header.
* **`401 Unauthorized`:** missing or wrong `Authorization` header. Check that you're sending both `apikey` and `Authorization`, and that they match what the operation needs (Anon Key for client-side; Service Role for server-side; user access token for user-scoped operations).
* **`400 invalid_mime_type`:** the `Content-Type` doesn't match the bucket's `allowed_mime_types`. Either fix the header or widen the bucket's allowlist.
* **`403 Forbidden`:** RLS on `storage.objects` denied the operation. Check your INSERT/UPDATE/SELECT policies match the path pattern your client is using.
* **TUS uploads stall, no error.** The TUS client is retrying invisibly. Check the network tab. Repeated PATCH requests with 5xx responses mean the issue is server-side (probably an RLS denial on UPDATE, since TUS PATCHes the same row). PATCH requests with 200 responses but no progress mean the client isn't sending the next chunk, so check your `onProgress` handler isn't throwing.
## Next steps
RLS patterns on storage.objects: own-files, public-read, role-based access.
Buckets vs objects, public vs private, the S3 prefix, MIME constraints.
Full /storage/v1/\* endpoint catalog.
How to get the user access token you'll use in browser-direct uploads.
# Streaming Responses
Source: https://docs.powabase.ai/guides/streaming-guide
Consume Server-Sent Events from agent runs. Learn to parse events, handle tool calls, manage errors, and build multi-turn conversations.
Agent runs stream results as Server-Sent Events (SSE). Each event is a JSON object prefixed with `data: ` on its own line. This guide covers parsing every event type, rendering tool calls in a UI, handling errors, and chaining multi-turn conversations with session IDs.
**Prerequisites:**
* An agent created (see Build an Agent guide)
Send a message to an agent and open an SSE stream. Read lines, filter for `data: ` prefixes, and parse each event as JSON.
**Endpoint:** `POST /api/agents/{id}/run/stream`
The -N flag in curl disables output buffering so events appear in real time.
```python Python theme={null}
import requests
import json
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json={"message": "Hello, what can you help me with?"},
stream=True,
)
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if text.startswith("data: "):
event = json.loads(text[6:])
print(event["event"], "->", event.get("content", "")[:80])
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify({ message: "Hello, what can you help me with?" }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
for (const line of text.split("\n")) {
if (line.startsWith("data: ")) {
const event = JSON.parse(line.slice(6));
console.log(event.event, "->", (event.content ?? "").slice(0, 80));
}
}
}
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "Hello, what can you help me with?"}'
```
Handle every event the stream can emit: start, chunk, tool\_call, tool\_result, step\_started, step\_completed, complete, and error.
**Endpoint:** `POST /api/agents/{id}/run/stream`
```python Python theme={null}
session_id = None
full_response = []
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if not text.startswith("data: "):
continue
event = json.loads(text[6:])
etype = event["event"]
if etype == "start":
session_id = event["session_id"]
print(f"Run started — session: {session_id}, run: {event['run_id']}")
elif etype == "step_started":
print(f"Step {event['step']} started")
elif etype == "chunk":
full_response.append(event["content"])
print(event["content"], end="", flush=True)
elif etype == "tool_call":
print(f"\n[Tool call: {event['tool_name']}({json.dumps(event.get('arguments', {}))})]")
elif etype == "tool_result":
print(f"[Tool result: {str(event.get('result', ''))[:100]}]")
elif etype == "step_completed":
print(f"\nStep {event['step']} completed")
elif etype == "complete":
print(f"\n\nRun complete — total tokens: {event.get('usage', {})}")
elif etype == "error":
print(f"\nError: {event.get('message', 'Unknown error')}")
```
```typescript TypeScript theme={null}
let sessionId: string | null = null;
const chunks: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
for (const line of text.split("\n")) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
switch (event.event) {
case "start":
sessionId = event.session_id;
console.log(`Run started — session: ${sessionId}, run: ${event.run_id}`);
break;
case "step_started":
console.log(`Step ${event.step} started`);
break;
case "chunk":
chunks.push(event.content);
process.stdout.write(event.content);
break;
case "tool_call":
console.log(`\n[Tool call: ${event.tool_name}(${JSON.stringify(event.arguments ?? {})})]`);
break;
case "tool_result":
console.log(`[Tool result: ${String(event.result ?? "").slice(0, 100)}]`);
break;
case "step_completed":
console.log(`\nStep ${event.step} completed`);
break;
case "complete":
console.log(`\n\nRun complete — usage: ${JSON.stringify(event.usage ?? {})}`);
break;
case "error":
console.error(`Error: ${event.message ?? "Unknown error"}`);
break;
}
}
}
```
```bash cURL theme={null}
# curl streams all events to stdout — pipe through jq for pretty printing
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "Search for information about our API"}'
# Each line is a JSON event prefixed with "data: "
# data: {"event":"start","run_id":"...","session_id":"..."}
# data: {"event":"chunk","content":"Based on..."}
# data: {"event":"complete","run_id":"..."}
```
Display tool calls and results as collapsible cards in a chat UI. Use the tool\_call event to show a loading indicator, then update with the tool\_result.
**Endpoint:** `POST /api/agents/{id}/run/stream`
Tool calls always appear in pairs: a tool\_call event followed by a tool\_result event. Multiple tool calls can occur in a single step if the agent decides to call several tools.
```python Python theme={null}
# Example: collect tool call/result pairs for UI rendering
tool_calls = []
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if not text.startswith("data: "):
continue
event = json.loads(text[6:])
if event["event"] == "tool_call":
tool_calls.append({
"name": event["tool_name"],
"arguments": event.get("arguments", {}),
"status": "running",
"result": None,
})
# UI: render a loading card for this tool call
elif event["event"] == "tool_result":
if tool_calls:
tool_calls[-1]["status"] = "complete"
tool_calls[-1]["result"] = event.get("result")
# UI: update the card with the result
elif event["event"] == "chunk":
# UI: append to the assistant message bubble
pass
```
```typescript TypeScript theme={null}
interface ToolCallUI {
name: string;
arguments: Record;
status: "running" | "complete";
result: unknown;
}
const toolCalls: ToolCallUI[] = [];
let assistantMessage = "";
// Inside your SSE parsing loop:
switch (event.event) {
case "tool_call":
toolCalls.push({
name: event.tool_name,
arguments: event.arguments ?? {},
status: "running",
result: null,
});
// Re-render: show a spinner card for the tool call
break;
case "tool_result":
if (toolCalls.length > 0) {
const last = toolCalls[toolCalls.length - 1];
last.status = "complete";
last.result = event.result;
}
// Re-render: replace spinner with result content
break;
case "chunk":
assistantMessage += event.content;
// Re-render: update the streaming text
break;
}
```
```bash cURL theme={null}
# Tool call events appear inline in the SSE stream:
# data: {"event":"tool_call","tool_name":"knowledge_base_search","arguments":{"query":"API setup"}}
# data: {"event":"tool_result","result":[{"text":"To set up the API...","score":0.92}]}
# data: {"event":"chunk","content":"Based on the documentation, "}
```
Handle error events from the stream and connection-level failures. Always wrap your stream reader in a try/catch.
**Endpoint:** `POST /api/agents/{id}/run/stream`
Error events have an optional `code` field (e.g., "rate\_limited", "context\_length\_exceeded"). Always check for HTTP-level errors before reading the stream.
```python Python theme={null}
try:
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json={"message": "What is the refund policy?"},
stream=True,
timeout=60,
)
response.raise_for_status()
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if not text.startswith("data: "):
continue
event = json.loads(text[6:])
if event["event"] == "error":
print(f"Agent error: {event.get('message', 'Unknown')}")
print(f"Error code: {event.get('code', 'N/A')}")
break
if event["event"] == "chunk":
print(event["content"], end="")
except requests.exceptions.ConnectionError:
print("Connection lost — retry with exponential backoff")
except requests.exceptions.Timeout:
print("Request timed out")
except json.JSONDecodeError as e:
print(f"Malformed SSE event: {e}")
```
```typescript TypeScript theme={null}
try {
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify({ message: "What is the refund policy?" }),
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
const body = await response.json();
throw new Error(`HTTP ${response.status}: ${body.error ?? "Unknown"}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
for (const line of text.split("\n")) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
if (event.event === "error") {
console.error(`Agent error: ${event.message ?? "Unknown"}`);
console.error(`Error code: ${event.code ?? "N/A"}`);
return; // Stop processing
}
if (event.event === "chunk") {
process.stdout.write(event.content);
}
}
}
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
console.error("Request timed out");
} else {
console.error("Connection error:", err);
}
}
```
```bash cURL theme={null}
# curl exits with non-zero status on connection errors
# Use --max-time for a timeout
curl -N --max-time 60 -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "What is the refund policy?"}'
# Check exit code: 0 = success, 28 = timeout, 7 = connection refused
```
The `start` event includes a `session_id`. Pass it in subsequent requests to continue the conversation with full message history.
**Endpoint:** `POST /api/agents/{id}/run/stream`
Sessions persist the full conversation history. You can also list session messages via GET /api/sessions/\{session\_id}/messages.
```python Python theme={null}
import json
def chat(agent_id: str, message: str, session_id: str | None = None) -> str:
"""Send a message and return (response_text, session_id)."""
body = {"message": message}
if session_id:
body["session_id"] = session_id
response = requests.post(
f"{BASE_URL}/api/agents/{agent_id}/run/stream",
headers=headers,
json=body,
stream=True,
)
result_text = []
sid = session_id
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if not text.startswith("data: "):
continue
event = json.loads(text[6:])
if event["event"] == "start":
sid = event["session_id"]
elif event["event"] == "chunk":
result_text.append(event["content"])
return "".join(result_text), sid
# First message — no session yet
answer1, session_id = chat(agent_id, "What products do you offer?")
print(answer1)
# Follow-up — same session, agent remembers context
answer2, session_id = chat(agent_id, "Which one is best for small teams?", session_id)
print(answer2)
```
```typescript TypeScript theme={null}
async function chat(
agentId: string,
message: string,
sessionId?: string,
): Promise<{ text: string; sessionId: string }> {
const body: Record = { message };
if (sessionId) body.session_id = sessionId;
const response = await fetch(`${BASE_URL}/api/agents/${agentId}/run/stream`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const chunks: string[] = [];
let sid = sessionId ?? "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split("\n")) {
if (!line.startsWith("data: ")) continue;
const event = JSON.parse(line.slice(6));
if (event.event === "start") sid = event.session_id;
if (event.event === "chunk") chunks.push(event.content);
}
}
return { text: chunks.join(""), sessionId: sid };
}
// First message
const turn1 = await chat(agentId, "What products do you offer?");
console.log(turn1.text);
// Follow-up with session context
const turn2 = await chat(agentId, "Which one is best for small teams?", turn1.sessionId);
console.log(turn2.text);
```
```bash cURL theme={null}
# Turn 1 — capture the session_id from the start event
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "What products do you offer?"}'
# Turn 2 — pass session_id for conversation continuity
curl -N -X POST '{BASE_URL}/api/agents/{agent_id}/run/stream' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "Which one is best for small teams?", "session_id": "{session_id}"}'
```
## What's Next
Understand the SSE protocol and event lifecycle in depth.
Create an agent from scratch with tools and knowledge bases.
Full endpoint documentation for agent runs.
# Upload Your First Document
Source: https://docs.powabase.ai/guides/upload-document
Upload a file (PDF, DOCX, images, etc.) and extract its content. Extracted text becomes available for knowledge base indexing.
Uploading a document creates a Source, the platform's representation of your file. After upload, an asynchronous extraction pipeline converts the file into structured page texts. You'll poll for status and then retrieve the extracted content.
**Prerequisites:**
* Authentication configured (see Authentication guide)
Send a multipart form-data request with your file. The server starts extraction automatically and returns the source metadata.
**Endpoint:** `POST /api/sources/upload`
```python Python theme={null}
with open("document.pdf", "rb") as f:
response = requests.post(
f"{BASE_URL}/api/sources/upload",
headers={"apikey": API_KEY, "Authorization": f"Bearer {API_KEY}"},
files={"file": ("document.pdf", f, "application/pdf")},
)
source = response.json()
source_id = source["id"]
print(f"Source created: {source_id}")
```
```typescript TypeScript theme={null}
const formData = new FormData();
formData.append("file", fileBlob, "document.pdf");
const response = await fetch(`${BASE_URL}/api/sources/upload`, {
method: "POST",
headers: { apikey: API_KEY, Authorization: `Bearer ${API_KEY}` },
body: formData,
});
const source = await response.json();
console.log("Source created:", source.id);
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/sources/upload' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-F "file=@document.pdf"
```
**Response:**
```json theme={null}
{
"id": "source-uuid",
"name": "document.pdf",
"file_type": "application/pdf",
"storage_path": "sources-{org}-{project}/{source_id}/document.pdf",
"extraction_status": "pending",
"task_id": "celery-task-uuid"
}
```
Poll the source until extraction\_status reaches a terminal state: extracted (success), attention\_required (partial: some pages failed but the source is still indexable), failed, or cancelled. Small documents typically take a few seconds.
**Endpoint:** `GET /api/sources/{id}`
```python Python theme={null}
import time
TERMINAL = {"extracted", "attention_required", "failed", "cancelled"}
while True:
response = requests.get(
f"{BASE_URL}/api/sources/{source_id}",
headers=headers,
)
body = response.json()
status = body["extraction_status"]
if status == "extracted":
print("Extraction complete!")
break
elif status == "attention_required":
print("Extraction partial:", body.get("error_message"))
break
elif status in ("failed", "cancelled"):
print(f"Extraction {status}:", body.get("error_message"))
break
time.sleep(2)
```
```typescript TypeScript theme={null}
const TERMINAL = new Set(["extracted", "attention_required", "failed", "cancelled"]);
let status = "pending";
while (!TERMINAL.has(status)) {
await new Promise((r) => setTimeout(r, 2000));
const res = await fetch(`${BASE_URL}/api/sources/${sourceId}`, { headers });
const data = await res.json();
status = data.extraction_status;
}
console.log("Extraction ended with status:", status);
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sources/{source_id}' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
```
Retrieve the extracted text. `GET /page-texts` returns `{ "page_texts": [string, ...], "count": N }`, where `page_texts` is an array of strings, one per page, in order. To fetch a single page, pass `?page=N`, which returns `{ "text": string, "page": N, "count": N }`.
**Endpoint:** `GET /api/sources/{id}/page-texts`
```python Python theme={null}
response = requests.get(
f"{BASE_URL}/api/sources/{source_id}/page-texts",
headers=headers,
)
body = response.json()
for i, text in enumerate(body["page_texts"], start=1):
print(f"Page {i}: {text[:100]}...")
```
```typescript TypeScript theme={null}
const res = await fetch(
`${BASE_URL}/api/sources/${sourceId}/page-texts`,
{ headers },
);
const body = await res.json();
body.page_texts.forEach((text: string, i: number) =>
console.log(`Page ${i + 1}: ${text.slice(0, 100)}...`),
);
```
```bash cURL theme={null}
curl '{BASE_URL}/api/sources/{source_id}/page-texts' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
```
## What's Next
Index your extracted content for semantic search.
Understand the extraction pipeline in depth.
Full endpoint documentation.
# User-managed pgvector
Source: https://docs.powabase.ai/guides/user-pgvector
pgvector is preloaded in every Powabase project. When to use the typed AI surface vs roll your own embeddings table, and how to set up HNSW indexes for fast vector search.
Powabase projects ship with [pgvector](https://github.com/pgvector/pgvector) preloaded. The `vector` extension is created at project provision time and used internally by `ai.embeddings`. You can use it for your own tables too: store embeddings in `public.*` and run vector similarity queries via PostgREST or direct SQL.
For most users, **the typed [Sources](/api-reference/sources) and [Knowledge Bases](/api-reference/knowledge-bases) surface is the right path.** It manages chunking, embedding, indexing, RLS, billing, and retrieval reranking for you. Rolling your own pgvector tables is for cases where the typed surface doesn't fit: custom embedding models, specific schema constraints, integration with non-Powabase pipelines.
This page covers the user-managed approach so you can choose the right tool for the job.
## When to use which
| You want… | Use |
| ------------------------------------------------------------------- | ----------------------------------------------------- |
| RAG over uploaded documents | Sources + KB (`/api/sources`, `/api/knowledge-bases`) |
| Hybrid search (vector + BM25 + rerank) | KB search (`/api/knowledge-bases/{id}/search`) |
| Custom embedding model / non-text data (image embeddings, audio) | User-managed pgvector |
| Schema-coupled embeddings ("each row has its own embedding column") | User-managed pgvector |
| Integration with an external indexing pipeline | User-managed pgvector |
| Per-user RLS that the typed KB surface doesn't yet support | User-managed pgvector |
The typed surface charges credits per indexing operation and per search; user-managed pgvector only charges credits for `vector_search` calls (see [Billing model](/concepts/billing-model)). For high-volume retrieval against a fixed corpus, user-managed can be cheaper, at the cost of more setup.
## The basic shape
A user-managed embeddings table is just a regular table with a `vector(N)` column where N is your embedding dimension.
```sql theme={null}
CREATE TABLE public.product_embeddings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
product_id uuid NOT NULL REFERENCES public.products(id) ON DELETE CASCADE,
embedding vector(1536) NOT NULL, -- OpenAI text-embedding-3-small dimension
meta jsonb DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Enable RLS like any other table
ALTER TABLE public.product_embeddings ENABLE ROW LEVEL SECURITY;
CREATE POLICY read_product_embeddings ON public.product_embeddings
FOR SELECT TO authenticated
USING (true); -- adapt to your ownership model
```
The dimension (1536 above) must match what your embedding model produces. Common choices:
* OpenAI `text-embedding-3-small`: **1536**
* OpenAI `text-embedding-3-large`: **3072**
* Cohere `embed-english-v3.0`: **1024**
* Voyage `voyage-3-large`: **1024**
* BGE M3: **1024**
Get the dimension wrong and inserts will fail with `expected N dimensions, not M`.
## Indexing for fast search
A `vector` column without an index does a sequential scan for every search: fine for thousands of rows, slow above tens of thousands. Two index types: HNSW and IVFFlat.
**HNSW** (Hierarchical Navigable Small World) is what Powabase uses internally and what most production setups want. It gives faster searches, slower builds, and no parameter tuning at query time:
```sql theme={null}
CREATE INDEX product_embeddings_hnsw_idx
ON public.product_embeddings
USING hnsw ((embedding::vector(1536)) vector_cosine_ops);
```
The double cast `embedding::vector(1536)` is what pgvector wants for HNSW with explicit dimension typing. Without it the index may not be picked up at query time.
`vector_cosine_ops` is the distance operator class:
* `vector_cosine_ops`: cosine distance (most common for text embeddings)
* `vector_l2_ops`: Euclidean distance
* `vector_ip_ops`: inner product (negative dot product)
Match the distance to whatever your embedding model recommends. Most text embedding models are L2-normalized, so cosine and inner-product give the same ranking; for non-normalized embeddings the choice matters.
**IVFFlat** is an older index type: faster to build, slower to query, needs `lists` tuning:
```sql theme={null}
CREATE INDEX product_embeddings_ivfflat_idx
ON public.product_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
```
`lists` is roughly `rows / 1000` for the rule-of-thumb default. Use HNSW unless you have a specific reason; it's strictly better for most workloads.
## Inserting embeddings
You compute the embedding in your application code (calling OpenAI, Cohere, etc.) and insert via PostgREST or SQL:
```typescript theme={null}
const embedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: productDescription,
});
await fetch(`${BASE_URL}/rest/v1/product_embeddings`, {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
product_id: productId,
embedding: embedding.data[0].embedding, // number[1536]
meta: { source: "product_description" },
}),
});
```
The `embedding` column expects a JSON number array; PostgREST converts it to pgvector's internal format on insert.
## Searching
Vector search uses the distance operators (`<=>` for cosine, `<->` for L2, `<#>` for negative inner product):
```sql theme={null}
SELECT id, product_id, 1 - (embedding <=> $1::vector) AS similarity
FROM public.product_embeddings
ORDER BY embedding <=> $1::vector
LIMIT 10;
```
The `1 - (embedding <=> $1)` converts cosine distance (lower = more similar) to similarity (higher = more similar) for client-friendly results.
Via PostgREST RPC:
```sql theme={null}
CREATE OR REPLACE FUNCTION public.search_products(
query_embedding vector(1536),
match_count int DEFAULT 10
)
RETURNS TABLE (id uuid, product_id uuid, similarity float)
LANGUAGE sql STABLE
AS $$
SELECT id, product_id, 1 - (embedding <=> query_embedding) AS similarity
FROM public.product_embeddings
ORDER BY embedding <=> query_embedding
LIMIT match_count;
$$;
GRANT EXECUTE ON FUNCTION public.search_products(vector, int) TO authenticated;
```
Call from your app:
```bash theme={null}
POST /rest/v1/rpc/search_products
{
"query_embedding": [0.123, 0.456, ...],
"match_count": 10
}
```
The RPC pattern keeps the query logic in the database and lets you index/optimize it independently of your app code.
## Hybrid search (vector + BM25)
For RAG-quality retrieval, combine vector similarity with BM25 keyword scoring. You'll need a `tsvector` column and matching GIN index:
```sql theme={null}
ALTER TABLE public.product_embeddings
ADD COLUMN content_tsv tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce((meta->>'content'), ''))
) STORED;
CREATE INDEX product_embeddings_content_tsv_idx
ON public.product_embeddings
USING gin (content_tsv);
```
Then combine in an RPC:
```sql theme={null}
CREATE OR REPLACE FUNCTION public.hybrid_search_products(
query_embedding vector(1536),
query_text text,
vector_weight float DEFAULT 0.7,
match_count int DEFAULT 10
)
RETURNS TABLE (id uuid, product_id uuid, score float)
LANGUAGE sql STABLE
AS $$
WITH vec AS (
SELECT id, product_id,
1 - (embedding <=> query_embedding) AS vec_score
FROM public.product_embeddings
ORDER BY embedding <=> query_embedding
LIMIT match_count * 4
),
bm25 AS (
SELECT id, product_id,
ts_rank(content_tsv, plainto_tsquery('english', query_text)) AS bm25_score
FROM public.product_embeddings
WHERE content_tsv @@ plainto_tsquery('english', query_text)
ORDER BY bm25_score DESC
LIMIT match_count * 4
)
SELECT
coalesce(vec.id, bm25.id) AS id,
coalesce(vec.product_id, bm25.product_id) AS product_id,
(vector_weight * coalesce(vec.vec_score, 0)) +
((1 - vector_weight) * coalesce(bm25.bm25_score, 0)) AS score
FROM vec
FULL OUTER JOIN bm25 USING (id)
ORDER BY score DESC
LIMIT match_count;
$$;
```
This is a basic weighted-sum hybrid; the typed KB surface uses reciprocal rank fusion (RRF), which holds up better across query types. For real RAG quality, the typed surface is still the right answer.
## When pgvector isn't enough
For very large corpora (10M+ embeddings), even HNSW gets slow. Three options:
1. **Use the typed KB surface.** Powabase's KB indexing strategies handle large corpora with techniques like PageIndex and GraphIndex that go beyond flat vector search.
2. **Specialized vector DB:** Pinecone, Weaviate, Qdrant. These add cost but are sometimes the right shape for billion-scale workloads.
3. **Quantization or dimensionality reduction.** Reduce the embedding size to 256 or 512 dimensions; faster but lower recall.
For most apps under 1M embeddings, user-managed pgvector is fine.
## Next steps
The typed surface for document ingestion if you decide pgvector-direct is too much work.
The typed search surface with hybrid retrieval and reranking.
What else is preloaded alongside pgvector.
For the SQL patterns this guide builds on.
# Build Workflows with Copilot
Source: https://docs.powabase.ai/guides/workflows-copilot
Describe what you want in natural language and let the AI copilot build the workflow graph for you. The copilot generates blocks and edges based on your description.
The AI copilot generates a complete workflow graph from a natural language description. You create a copilot session linked to a workflow, describe what you want, and the copilot produces the blocks and edges. Iterate over multiple chat turns to refine the workflow.
**Prerequisites:**
* Authentication configured (see Authentication guide)
Create an empty workflow that the copilot will populate.
**Endpoint:** `POST /api/workflows`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/workflows",
headers=headers,
json={"name": "Email Classifier"},
)
workflow = response.json()
wf_id = workflow["id"]
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/workflows`, {
method: "POST",
headers,
body: JSON.stringify({ name: "Email Classifier" }),
});
const workflow = await response.json();
const wfId = workflow.id;
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "Email Classifier"}'
```
Create a copilot session linked to the workflow.
**Endpoint:** `POST /api/copilot/sessions`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/copilot/sessions",
headers=headers,
json={"workflow_id": wf_id},
)
session = response.json()
session_id = session["id"]
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/copilot/sessions`, {
method: "POST",
headers,
body: JSON.stringify({ workflow_id: wfId }),
});
const session = await response.json();
const sessionId = session.id;
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/copilot/sessions' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"workflow_id": "{wf_id}"}'
```
Send a natural language description via the chat endpoint. The copilot responds with a streaming SSE response that includes the generated workflow graph.
**Endpoint:** `POST /api/copilot/sessions/{id}/chat`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/copilot/sessions/{session_id}/chat",
headers=headers,
json={
"message": "Build a workflow that takes an email as input, classifies it as spam/not-spam using an LLM, and outputs the classification with confidence score.",
},
stream=True,
)
message_id = None
for line in response.iter_lines():
if not line:
continue
text = line.decode("utf-8")
if text.startswith("data: "):
event = json.loads(text[6:])
if event.get("message_id"):
message_id = event["message_id"]
if event["event"] == "chunk":
print(event["content"], end="")
print(f"\nMessage ID: {message_id}")
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/copilot/sessions/${sessionId}/chat`, {
method: "POST",
headers,
body: JSON.stringify({
message: "Build a workflow that takes an email as input, classifies it as spam/not-spam using an LLM, and outputs the classification with confidence score.",
}),
});
// Parse SSE stream for chunks and message_id
```
```bash cURL theme={null}
curl -N -X POST '{BASE_URL}/api/copilot/sessions/{session_id}/chat' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"message": "Build a workflow that classifies emails as spam or not-spam"}'
```
Save the copilot's generated workflow graph as a snapshot. This applies the blocks and edges to the workflow.
**Endpoint:** `POST /api/copilot/sessions/{id}/messages/{mid}/snapshot`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/copilot/sessions/{session_id}/messages/{message_id}/snapshot",
headers=headers,
)
print(response.json())
```
```typescript TypeScript theme={null}
await fetch(
`${BASE_URL}/api/copilot/sessions/${sessionId}/messages/${messageId}/snapshot`,
{ method: "POST", headers },
);
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/copilot/sessions/{session_id}/messages/{message_id}/snapshot' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
```
Run the copilot-built workflow with input data.
**Endpoint:** `POST /api/workflows/{id}/execute`
You can also build workflows programmatically via PUT /api/workflows/\{id}/graph; see the Build Workflows Programmatically guide.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/workflows/{wf_id}/execute",
headers=headers,
json={"variables": {"email": "Congratulations! You've won a free iPhone..."}},
)
print(response.json())
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/workflows/${wfId}/execute`, {
method: "POST",
headers,
body: JSON.stringify({ variables: { email: "Congratulations! You've won a free iPhone..." } }),
});
console.log(await response.json());
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows/{wf_id}/execute' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"variables": {"email": "Congratulations! You have won a free iPhone..."}}'
```
## What's Next
Fine-tune workflows by editing the graph directly.
Understand block types and graph execution.
Full endpoint documentation.
# Build Workflows Programmatically
Source: https://docs.powabase.ai/guides/workflows-programmatic
Create automated workflows by defining blocks and edges via the API. Blocks are processing steps (LLM calls, agent runs, conditions). Edges connect them into a directed graph.
Workflows are deterministic automation pipelines. Unlike agents (which decide what to do), workflows follow a fixed graph of blocks and edges. This guide creates a simple summarizer workflow and deploys it as a webhook.
**Prerequisites:**
* Authentication configured (see Authentication guide)
Create an empty workflow container.
**Endpoint:** `POST /api/workflows`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/workflows",
headers=headers,
json={"name": "Document Summarizer", "description": "Summarizes uploaded documents"},
)
workflow = response.json()
wf_id = workflow["id"]
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/workflows`, {
method: "POST",
headers,
body: JSON.stringify({ name: "Document Summarizer", description: "Summarizes uploaded documents" }),
});
const workflow = await response.json();
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "Document Summarizer"}'
```
Save blocks (processing steps) and edges (connections between them) as a complete graph. The block registry recognizes these canonical types: `starter`, `agent`, `code`, `condition`, `general_api`, `platform_api`, `response`, `split`, `webhook`, `orchestration` (`function` and `api_call` are accepted as back-compat aliases for `code` and `general_api`). The example below wires a `starter` that injects workflow variables, runs an `agent` block to produce a summary, then returns the agent's output through a `response` block.
**Endpoint:** `PUT /api/workflows/{id}/graph`
```python Python theme={null}
# Assumes an agent already exists. Create one with POST /api/agents
# if you don't have one yet — see guides/build-agent.
agent_id = "..." # existing agent UUID
response = requests.put(
f"{BASE_URL}/api/workflows/{wf_id}/graph",
headers=headers,
json={
"blocks": [
{"id": "start", "type": "starter", "config": {}, "position": {"x": 0, "y": 0}},
{"id": "summarize", "type": "agent", "config": {
"agent_id": agent_id,
"message": "Summarize the following document:\n\n{{variables.text}}",
}, "position": {"x": 300, "y": 0}},
{"id": "out", "type": "response", "config": {}, "position": {"x": 600, "y": 0}},
],
"edges": [
{"source": "start", "target": "summarize"},
{"source": "summarize", "target": "out"},
],
},
)
print(response.json())
```
```typescript TypeScript theme={null}
// Assumes an agent already exists. Create one with POST /api/agents
// if you don't have one yet — see guides/build-agent.
const agentId = "..."; // existing agent UUID
await fetch(`${BASE_URL}/api/workflows/${wfId}/graph`, {
method: "PUT",
headers,
body: JSON.stringify({
blocks: [
{ id: "start", type: "starter", config: {}, position: { x: 0, y: 0 } },
{ id: "summarize", type: "agent", config: {
agent_id: agentId,
message: "Summarize the following document:\n\n{{variables.text}}",
}, position: { x: 300, y: 0 } },
{ id: "out", type: "response", config: {}, position: { x: 600, y: 0 } },
],
edges: [
{ source: "start", target: "summarize" },
{ source: "summarize", target: "out" },
],
}),
});
```
```bash cURL theme={null}
curl -X PUT '{BASE_URL}/api/workflows/{wf_id}/graph' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"blocks": [...], "edges": [...]}'
```
Unknown block types are rejected with `400 Unknown block type`. See the [Workflows concept page](/concepts/workflows-concept) for what each block does.
Run the workflow with input data. Returns execution results.
**Endpoint:** `POST /api/workflows/{id}/execute`
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/workflows/{wf_id}/execute",
headers=headers,
json={"variables": {"text": "Your document content here..."}},
)
result = response.json()
print(result)
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/workflows/${wfId}/execute`, {
method: "POST",
headers,
body: JSON.stringify({ variables: { text: "Your document content here..." } }),
});
const result = await response.json();
console.log(result);
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/workflows/{wf_id}/execute' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}" \
-H "Content-Type: application/json" \
-d '{"variables": {"text": "Your document content here..."}}'
```
Two activation modes:
* **Deploy** (`POST /api/workflows/{id}/deploy`) sets `state = "deployed"`. The webhook accepts unlimited calls until you `undeploy`.
* **Arm** (`POST /api/workflows/{id}/arm`) leaves `state = "internal"` but opens a 10-minute window during which the webhook accepts exactly one call. After it fires (or expires), you must arm again.
Use deploy for production integrations; use arm for one-shot tests.
The `webhook_id` and `webhook_secret` are properties of the **webhook block** itself, stored in `block.config`. The studio editor mints them client-side when you drag in a webhook block. To retrieve them programmatically, fetch the workflow and pull them from the block's config.
If you create the webhook block via API instead of the UI (`PUT /api/workflows/{id}/graph`), you must mint both `webhook_id` and `webhook_secret` yourself and include them in the block's `config`. The server does not generate them. `webhook_id` MUST be a valid UUID; the trigger endpoint validates it and returns 400 otherwise. `webhook_secret` can be any non-empty string (the studio editor uses `crypto.randomUUID()` for both). A webhook block with no secret is silently un-triggerable (the trigger endpoint returns 401).
```python Python theme={null}
# Deploy (or arm) the workflow
requests.post(f"{BASE_URL}/api/workflows/{wf_id}/deploy", headers=headers)
# OR: requests.post(f"{BASE_URL}/api/workflows/{wf_id}/arm", headers=headers)
# Look up webhook credentials from the saved graph (assumes one webhook block)
wf = requests.get(f"{BASE_URL}/api/workflows/{wf_id}", headers=headers).json()
webhook_block = next(b for b in wf["blocks"] if b["type"] == "webhook")
webhook_id = webhook_block["config"]["webhook_id"]
webhook_secret = webhook_block["config"]["webhook_secret"]
```
```typescript TypeScript theme={null}
// Deploy (or arm) the workflow
await fetch(`${BASE_URL}/api/workflows/${wfId}/deploy`, { method: "POST", headers });
// Look up webhook credentials from the saved graph (assumes one webhook block)
const wf = await fetch(`${BASE_URL}/api/workflows/${wfId}`, { headers }).then(r => r.json());
const webhookBlock = wf.blocks.find((b: { type: string }) => b.type === "webhook");
const webhookId = webhookBlock.config.webhook_id;
const webhookSecret = webhookBlock.config.webhook_secret;
```
```bash cURL theme={null}
# Deploy (or arm)
curl -X POST '{BASE_URL}/api/workflows/{wf_id}/deploy' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
# Read graph and pull webhook_id / webhook_secret from the block whose type=="webhook"
curl '{BASE_URL}/api/workflows/{wf_id}' \
-H "apikey: {API_KEY}" \
-H "Authorization: Bearer {API_KEY}"
```
The deploy endpoint returns `{"ok": true, "state": "deployed"}` and arm returns `{"ok": true, "armed_until": ""}`. Neither response contains the webhook credentials.
Call the webhook endpoint from any external system. No platform API key is needed; auth is per-webhook via the secret, sent either as a Bearer header (preferred) or a `?token=` query param. The request body becomes the workflow's input variables directly.
Use one auth mechanism or the other, not both. The server checks the header with `auth_header.lower().startswith("bearer ")` (note the trailing space). If your `Authorization` header is exactly `Bearer ` (trailing space, no token), which is what `Bearer ${secret || ""}` produces when `secret` is falsy, the server reads an empty token and 401s without consulting `?token=`. The `?token=` fallback only fires when the header doesn't start with `Bearer `.
**Endpoint:** `POST /api/webhooks/{webhook_id}`
If the workflow is `deployed`, the webhook accepts unlimited calls. If only armed, the webhook accepts exactly one call within the 10-minute window; re-arm to fire it again.
```python Python theme={null}
response = requests.post(
f"{BASE_URL}/api/webhooks/{webhook_id}",
headers={"Authorization": f"Bearer {webhook_secret}", "Content-Type": "application/json"},
json={"text": "Document to summarize..."},
)
print(response.json())
```
```typescript TypeScript theme={null}
const response = await fetch(`${BASE_URL}/api/webhooks/${webhookId}`, {
method: "POST",
headers: {
Authorization: `Bearer ${webhookSecret}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text: "Document to summarize..." }),
});
console.log(await response.json());
```
```bash cURL theme={null}
curl -X POST '{BASE_URL}/api/webhooks/{webhook_id}' \
-H "Authorization: Bearer {webhook_secret}" \
-H "Content-Type: application/json" \
-d '{"text": "Document to summarize..."}'
```
## What's Next
Build workflows with natural language.
Understand block types and graph execution.
Full endpoint documentation.