diff --git a/content/docs/ai/agents.mdx b/content/docs/ai/agents.mdx
index c332864c6b..dabbca6a9d 100644
--- a/content/docs/ai/agents.mdx
+++ b/content/docs/ai/agents.mdx
@@ -43,6 +43,13 @@ instead — see the callout in the [AI Overview](/docs/ai).)
## Connect your AI (BYO-AI over MCP)
+
+Step-by-step client setup (Claude Code, Claude Desktop, `.mcp.json`, API keys)
+with verification and troubleshooting lives in
+[Connect an MCP Client](/docs/ai/connect-mcp). The summary below covers the
+architecture.
+
+
Every deployment serves MCP at `/api/v1/mcp` by default (a core platform
capability — set `OS_MCP_SERVER_ENABLED=false` to opt out), with two
authentication tracks: The in-product entry point is the
diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx
new file mode 100644
index 0000000000..47e0cfebce
--- /dev/null
+++ b/content/docs/ai/connect-mcp.mdx
@@ -0,0 +1,159 @@
+---
+title: Connect an MCP Client
+description: Point Claude Code, Claude Desktop, or any MCP client at your running app — OAuth or API key — and verify the agent can see and operate it.
+---
+
+# Connect an MCP Client
+
+Every ObjectStack deployment is already an MCP server. The runtime serves the
+[Model Context Protocol](https://modelcontextprotocol.io) at **`/api/v1/mcp`**
+— on by default, no plugin to install, no configuration step. Your objects and
+exposed actions become typed tools the moment you define them; this page is
+about the only thing left to do: **connecting a client and proving it works**.
+
+
+To turn the surface off, set `OS_MCP_SERVER_ENABLED=false` — the endpoint then
+returns 404 and the **Setup → Connect an Agent** page disappears with it. See
+[environment variables](/docs/deployment/environment-variables#mcp-server).
+
+
+## Claude Code (one command)
+
+Interactive clients use OAuth — each deployment is its own OAuth 2.1
+authorization server, so there are no admin-minted credentials to pass around.
+The first tool call opens a browser login and you connect **as yourself**:
+
+```bash
+# local dev server
+claude mcp add --transport http my-app http://localhost:3000/api/v1/mcp
+
+# a deployed instance
+claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcp
+```
+
+For headless use (CI, containers) skip OAuth and attach an
+[API key](#headless-api-keys) instead:
+
+```bash
+claude mcp add --transport http my-app https://your-deployment.example.com/api/v1/mcp \
+ --header "x-api-key: osk_..."
+```
+
+## Claude Desktop and claude.ai
+
+**Settings → Connectors → Add custom connector**, then paste the MCP URL
+(`https://your-deployment.example.com/api/v1/mcp`). The first use walks through
+the same browser login.
+
+## Any MCP client (`.mcp.json`)
+
+Clients that read an `mcpServers` map connect the same way. With an API key:
+
+```json
+{
+ "mcpServers": {
+ "objectstack": {
+ "type": "http",
+ "url": "https://your-deployment.example.com/api/v1/mcp",
+ "headers": { "x-api-key": "osk_..." }
+ }
+ }
+}
+```
+
+## Headless: API keys
+
+Mint a key from **Setup → Connect an Agent** in the Console (which also shows
+copy-paste-ready connect snippets per client), or over REST:
+
+```bash
+curl -b cookies.txt -X POST https://your-deployment.example.com/api/v1/keys
+# → { "key": "osk_..." } — shown once; store it in your secret manager
+```
+
+Send it on every request in any of three equivalent forms:
+
+| Header | Example |
+|:---|:---|
+| `x-api-key` | `x-api-key: osk_...` |
+| `Authorization: ApiKey` | `Authorization: ApiKey osk_...` |
+| `Authorization: Bearer` | `Authorization: Bearer osk_...` (recognized by the `osk_` prefix) |
+
+
+OAuth requires TLS — plain-HTTP deployments (except `localhost`) fall back to
+**API-key-only**: the browser-login track is disabled rather than allowed to
+run insecurely.
+
+
+## What the agent gets
+
+Ten data and action tools, generated from your metadata:
+
+| Tool | What it does |
+|:---|:---|
+| `list_objects` / `describe_object` | Discover which objects exist and their fields |
+| `query_records` / `get_record` | Read data (list queries are capped at 50 rows per page by default) |
+| `aggregate_records` | Grouped aggregation (registered when the active driver supports it) |
+| `create_record` / `update_record` / `delete_record` | Write data |
+| `list_actions` / `run_action` | Discover and invoke your business actions by name |
+
+Two exposure rules to know:
+
+- **Objects are exposed automatically** — except `sys_*` system objects, which
+ are blocked fail-closed.
+- **Actions require the author's opt-in**: `ai: { exposed: true }` plus an
+ `ai.description` of at least 40 characters, and the action must be callable
+ without a UI (`script` with a body or registered handler, or `flow`).
+ See [Actions as Tools](/docs/ai/actions-as-tools) and
+ [Actions](/docs/ui/actions).
+
+## The security model
+
+- **Every call runs as the caller.** The MCP bridge resolves the same
+ `ExecutionContext` as a REST request, so RBAC, row-level security, and
+ field-level security apply to the agent exactly as they do to a person in
+ the Console. Sparse results or denied writes usually mean governance is
+ *working*, not that the connection is broken.
+- **OAuth scopes narrow the toolset.** Tokens carry `data:read`,
+ `data:write`, and `actions:execute` scopes — tools outside the granted
+ scopes are not even registered for that session. API-key and session
+ callers get the full set, still permission-checked per call.
+- **Action bodies run as trusted app code** once invoked (the `ai.exposed`
+ gate and `requiredPermissions` are checked at *invoke* time). Treat writing
+ an action as a code-review-worthy act — that's the real security boundary.
+- An action can also declare `ai.requiresConfirmation`; destructive-looking
+ actions (`confirmText`, danger variants) default to requiring it.
+
+## Verify the connection
+
+Ask the agent something only the live schema can answer:
+
+> "What objects does this app have, and what fields does the main one carry?"
+
+You should see `list_objects` and `describe_object` fire. The natural working
+pattern for an agent is `list_objects` → `describe_object` → `query_records` →
+`run_action` — if all four work, the connection is fully operational.
+
+Agents work noticeably better with the app's **skill file**: download it from
+`GET /api/v1/mcp/skill`, or install the
+[official Claude plugin](https://github.com/objectstack-ai/claude-plugin)
+(`claude plugin marketplace add objectstack-ai/claude-plugin`) which bundles the
+skill and a guided `/objectstack:connect` command.
+
+## Troubleshooting
+
+| Symptom | Cause → fix |
+|:---|:---|
+| `404` on `/api/v1/mcp` | The surface is disabled — unset `OS_MCP_SERVER_ENABLED` (default is on) |
+| `501 Not Implemented` | The MCP plugin isn't part of this build — check your stack's plugins |
+| `401` on every call | Anonymous or invalid credentials. Interactive clients: complete the browser login (the `WWW-Authenticate` header advertises the OAuth metadata). Headless: check the `osk_` key and header spelling |
+| `403 insufficient_scope` | The OAuth token lacks the scope for that tool family (e.g. writes without `data:write`) — reconnect and grant the scope |
+| An action is missing from `list_actions` | `ai.exposed` is not `true`, `ai.description` is shorter than 40 characters, the type isn't headless-callable (`url` / `modal` / `form` never appear), it targets a `sys_*` object, or the caller fails its `requiredPermissions` |
+| Reads return few rows / writes denied | Working as designed — the caller's permissions and RLS apply. Verify with the same user in the Console |
+
+## Related
+
+- [Actions as Tools](/docs/ai/actions-as-tools) — the `run_action` bridge and its governance
+- [Actions](/docs/ui/actions) — defining the actions you expose
+- [Your app as an MCP server](/docs/api#your-app-as-an-mcp-server) — the API-level view
+- [AI Agents](/docs/ai/agents) — building agents *inside* your app (the other direction)
diff --git a/content/docs/ai/meta.json b/content/docs/ai/meta.json
index 3140ed6a8f..6fdbbcf2ef 100644
--- a/content/docs/ai/meta.json
+++ b/content/docs/ai/meta.json
@@ -3,6 +3,7 @@
"icon": "Bot",
"pages": [
"index",
+ "connect-mcp",
"agents",
"actions-as-tools",
"knowledge-rag",
diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx
new file mode 100644
index 0000000000..c373e03419
--- /dev/null
+++ b/content/docs/ui/actions.mdx
@@ -0,0 +1,243 @@
+---
+title: Actions
+description: Declarative buttons with server-side behavior — define once, bind to lists, records, and navigation, permission-check on both surfaces, and optionally expose to AI.
+---
+
+# Actions
+
+An action is a **button declared as metadata**: where it appears
+(`locations`), when it's visible (`visible`), who may run it
+(`requiredPermissions`), and what it executes — an inline sandboxed script, a
+registered server handler, a flow, or a URL. The same declaration renders in
+the Console, executes over REST, and (with an explicit opt-in) becomes an AI
+tool over MCP.
+
+The types you'll actually use:
+
+| `type` | What it does | Server behavior |
+|:---|:---|:---|
+| `script` *(default)* | Run server-side logic | Inline `body` **or** a handler registered via `target` |
+| `flow` | Launch a flow (e.g. a screen-flow wizard) | `target` names the flow |
+| `url` | Navigate / open a link | `target` is the URL (`${ctx.record.id}` interpolation supported) |
+| `modal` | Open a modal page to collect input, then submit to a handler | `target` names the modal page |
+
+
+The schema also accepts `api` and `form` types, but they have **no runtime
+executor / renderer today** — stick to the four above. Likewise prefer
+`confirmText`/`params` over schema properties that are not yet wired
+(`shortcut`, `bulkEnabled`, `timeout`).
+
+
+## Define your first action
+
+From the bundled Todo example — a "Mark Complete" button on the task list and
+record header:
+
+```typescript title="src/actions/task.actions.ts"
+import { defineAction } from '@objectstack/spec/ui';
+
+export const CompleteTaskAction = defineAction({
+ name: 'complete_task',
+ label: 'Mark Complete',
+ objectName: 'todo_task', // which object this action belongs to
+ icon: 'check-circle',
+ type: 'script',
+ target: 'completeTask', // resolved to the handler registered below
+ locations: ['record_header', 'list_item'],
+ successMessage: 'Task marked as complete!',
+ refreshAfter: true,
+ ai: {
+ exposed: true,
+ description: 'Mark a todo task as complete. Use when the user says a task is done or finished.',
+ },
+});
+```
+
+Register it in your stack — top-level actions carrying an `objectName` are
+merged into that object automatically (and ordered by `order`):
+
+```typescript title="objectstack.config.ts"
+export default defineStack({
+ // ...
+ actions: Object.values(actions),
+});
+```
+
+## Give it server behavior — two paths
+
+### Path A: inline `body` (sandboxed)
+
+Self-contained logic ships inside the metadata and runs in the server sandbox
+— signature `(input, ctx)`, with `ctx.api.object(name)` for data access, a
+5-second default timeout, and declared `capabilities`:
+
+```typescript
+export const MarkDoneAction = defineAction({
+ name: 'showcase_mark_done',
+ label: 'Mark Done',
+ objectName: 'showcase_task',
+ type: 'script',
+ body: {
+ language: 'js',
+ source:
+ "var id = ctx.recordId || (ctx.record && ctx.record.id);" +
+ "if (!id) throw new Error('No record to mark done');" +
+ "await ctx.api.object('showcase_task').update({ id: id, done: true, progress: 100 });" +
+ "return { ok: true, id: id };",
+ capabilities: ['api.write'],
+ },
+ successMessage: 'Task marked done.',
+ visible: '!record.done',
+ locations: ['list_item', 'record_header', 'record_section'],
+ refreshAfter: true,
+});
+```
+
+Body-carrying actions are **registered automatically** at boot.
+
+### Path B: a registered handler (full TypeScript)
+
+For logic that belongs in real source files, point `target` at a handler name
+and register it in your config's `onEnable` lifecycle hook:
+
+```typescript title="src/actions/task.handlers.ts"
+export async function completeTask(ctx: ActionContext): Promise {
+ const { record, engine } = ctx; // ctx = { record, user, engine, params }
+ await engine.update('todo_task', record.id as string, {
+ status: 'completed',
+ completed_date: new Date().toISOString(),
+ });
+}
+```
+
+```typescript title="objectstack.config.ts"
+export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[]) => void } }) => {
+ ctx.ql.registerAction('todo_task', 'completeTask', completeTask);
+};
+```
+
+
+**The "dead button" trap:** only actions with an inline `body` register
+themselves. A `script` action whose `target` names a handler that was never
+`registerAction`-ed compiles fine but throws `Action 'x' on object 'y' not
+found` at click time. (An action with *neither* `body` nor `target` is
+rejected at authoring time.) Also note the handler's `engine` facade is
+**trusted** — it bypasses row- and field-level security, so enforce any
+caller-specific rules yourself.
+
+
+## Bind it to the UI
+
+`locations` is the primary binding — the action appears wherever it declares:
+
+| Location | Where the button renders |
+|:---|:---|
+| `list_toolbar` | List view toolbar (no record context) |
+| `list_item` | Per-row menu in list views |
+| `record_header` | Record page header |
+| `record_more` | Record page overflow ("…") menu |
+| `record_related` | Related-list sections |
+| `record_section` | Named action bars on record pages |
+| `global_nav` | App-level navigation |
+
+Surfaces can also reference actions **by name**:
+
+```typescript
+// List views — row and bulk menus
+defineView({
+ // ...
+ rowActions: ['complete_task'],
+ bulkActions: ['showcase_bulk_reassign'],
+});
+
+// Record pages — a quick-actions bar
+{ type: 'record:quick_actions',
+ properties: { location: 'record_section', actionNames: ['showcase_mark_done'] } }
+
+// App navigation — an action as a nav item
+{ type: 'action', actionDef: { actionName: 'crm_convert_lead' } }
+```
+
+
+Naming an action in a widget does **not** bypass location filtering — the
+engine still requires the action to declare the matching location (that's why
+`MarkDoneAction` above includes `record_section`).
+
+
+## Collect input and shape the UX
+
+- **`params`** — prompt the user for input before execution. Prefer
+ field-backed params (`{ field: 'due_date' }`) which inherit the object
+ field's label, type, and validation; inline params
+ (`{ name, label, type, required, options }`) cover the rest.
+ `defaultFromRow` prefills from the current record.
+- **`confirmText`** — confirmation dialog before running.
+- **`successMessage` / `errorMessage` / `refreshAfter`** — post-run feedback
+ and an automatic data refresh.
+- **`resultDialog`** — a one-time reveal dialog for output the user must copy
+ (generated tokens, export links).
+- **`variant` / `icon` / `order`** — presentation and sort position.
+
+## Permissions and visibility
+
+- **`requiredPermissions: ['can_close_tickets']`** is a **dual-surface gate**
+ (one declaration, two enforcement points): the server rejects unauthorized
+ calls with 403, and the UI hides or disables the button for the same users.
+ Unset means no gate beyond object CRUD permissions. Referenced capabilities
+ must exist — `os lint` checks that.
+- **`visible`** is a CEL predicate evaluated **fail-closed**: an expression
+ that throws hides the action silently. Two rules save real debugging time:
+ always prefix record fields (`record.status != "closed"`, never a bare
+ `status`), and keep it to a single operand — see the
+ [formulas guide](/docs/data-modeling/formulas) for CEL syntax.
+- **`requiresFeature`** ties visibility to a feature flag (compiled into a
+ `visible` predicate).
+
+## Call it over REST
+
+Every action is also an endpoint — the Console button and the API call run
+the same gate and handler:
+
+```bash
+curl -b cookies.txt -X POST \
+ https://your-app.example.com/api/v1/actions/todo_task/complete_task \
+ -H "Content-Type: application/json" \
+ -d '{ "recordId": "rec_123", "params": {} }'
+# → { "success": true, "data": ... } (errors: { "success": false, "error": ... })
+```
+
+Global (object-less) actions post to `/api/v1/actions/global/:action`. For
+credentials, see [API Authentication](/docs/api#authentication).
+
+## Expose it to AI (MCP)
+
+Actions are **not** AI-visible by default. Opting in takes two fields — and
+makes the action a governed MCP tool alongside the data tools:
+
+```typescript
+ai: {
+ exposed: true,
+ description: 'At least 40 characters explaining when an agent should use this action.',
+}
+```
+
+Only headless-callable types appear (`script` with body or handler, `flow`);
+`url`/`modal` never do. The caller's identity and `requiredPermissions` are
+enforced per invocation. Details: [Actions as Tools](/docs/ai/actions-as-tools)
+and [Connect an MCP Client](/docs/ai/connect-mcp).
+
+## Troubleshooting
+
+| Symptom | Cause → fix |
+|:---|:---|
+| Button doesn't appear | `locations` doesn't include the surface; or the `visible` CEL throws (bare field name, multiple operands) and fail-closed hides it; or the user fails `requiredPermissions` |
+| Click → `Action 'x' … not found` | `target`-style script with no registered handler — add the `registerAction` call in `onEnable` (Path B above) |
+| Appears in UI, missing from `list_actions` (MCP) | `ai.exposed` not `true`, `ai.description` under 40 characters, or the type isn't headless-callable |
+| Runs but the list looks stale | Add `refreshAfter: true` |
+
+## Related
+
+- [Actions as Tools](/docs/ai/actions-as-tools) — the AI exposure model in depth
+- [Views](/docs/ui/views) — `rowActions` / `bulkActions` binding
+- [Action protocol](/docs/protocol/objectui/actions) — the full spec narrative
+- [Action schema reference](/docs/references/ui/action) — every property, generated from the spec
diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json
index 0a56e5bca3..2385275725 100644
--- a/content/docs/ui/meta.json
+++ b/content/docs/ui/meta.json
@@ -6,6 +6,7 @@
"apps",
"pages",
"views",
+ "actions",
"dashboards",
"forms",
"doc-pages",
diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx
index 2f2ae43eb2..16fa0fc795 100644
--- a/content/docs/ui/views.mdx
+++ b/content/docs/ui/views.mdx
@@ -64,8 +64,8 @@ const taskListView = {
| `pagination` | `object` | optional | Pagination settings |
| `selection` | `object` | optional | Row selection mode |
| `navigation` | `object` | optional | Row click navigation |
-| `rowActions` | `array` | optional | Per-row action buttons |
-| `bulkActions` | `array` | optional | Bulk selection actions |
+| `rowActions` | `array` | optional | Per-row action buttons, by action name — see [Actions](/docs/ui/actions) |
+| `bulkActions` | `array` | optional | Bulk selection actions, by action name — see [Actions](/docs/ui/actions) |
| `inlineEdit` | `boolean` | optional | Enable inline editing |
| `exportOptions` | `string[]` | optional | Enabled export formats (`csv`, `xlsx`, `pdf`, `json`) |