# m8tes > Run agents through the m8tes API. Includes 190+ managed integrations, memory, scheduling, streaming, and human approvals. Set user_id to isolate each customer's data. --- # Introduction Source: https://www.m8tes.ai/docs/introduction m8tes is a hosted agent SDK: agents as API resources, through a Python SDK, TypeScript SDK, and REST API. You write the prompt and pick the tools; m8tes runs everything else. - Hosted runtime: every run executes in an isolated sandbox - Every end user gets an isolated agent via [`user_id`](/docs/users) - 190+ [integrations](/docs/tools), [add any MCP with custom permissions](/docs/custom-tools), and [computer use](/docs/computer-use) - [Memory](/docs/memories), [model choice](/docs/models), [scheduling and triggers](/docs/scheduling) - [Human-in-the-loop approvals](/docs/human-in-the-loop), [streaming](/docs/streaming), [webhook events](/docs/webhooks-guide) - [Files and reports](/docs/files), [billing with per-user spend caps](/docs/billing-usage) ## Authentication One API key (starts with `m8_`) in the `M8TES_API_KEY` env var: ```python from m8tes import M8tes client = M8tes() # reads M8TES_API_KEY ``` ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); // reads M8TES_API_KEY ``` Get a key from the [Developer dashboard](https://www.m8tes.ai/developer), or [create an account](/docs/quickstart#python). Coming from LangGraph, CrewAI, or eve? See [m8tes API vs. agent frameworks](/docs/vs-frameworks). For what people build with it, see [Use cases](/docs/use-cases). Next: [Quick start](/docs/quickstart) · [Use your coding agent](/docs/build-with-agents) · [API reference](/docs/api-reference) # Quick start Source: https://www.m8tes.ai/docs/quickstart Run your first agent. New API accounts get **$1** test credit on one model — no card and no OAuth required. | Building with | Start here | |---|---| | **Python** | [Install and run below](#python) | | **TypeScript / Node** | [TypeScript](#typescript) | | **React / Next.js** | [React / Next.js](#react--nextjs) · full guide: [Embed a UI](/docs/embed-a-ui) | ![Create an account → $1 test credit → Run an agent](/docs/diagrams/quickstart.svg) ## Python ### 1. Install ```bash pip install -U "m8tes>=4.49.0" ``` ### 2. Create your account Already registered? Get your key from the [Developer dashboard](https://www.m8tes.ai/developer). ```python from getpass import getpass import m8tes account = m8tes.signup("you@example.com", getpass("Choose a password: "), "yourname") print(account.api_key) ``` Set the key in your terminal: ```bash export M8TES_API_KEY=m8_your_key_here ``` ### 3. Run your first agent ### Try the API with $1 test credit API signups get **$1** promotional prepaid credit. Spend it on `deepseek-v4-1-flash` only. Other gateway models need a [top-up](/docs/billing-usage#prepaid-balance) or a [connected provider](#develop-on-your-model-subscription). One in-flight run at a time on the test balance. The first reply can take up to a minute while your agent's sandbox starts. ```python from m8tes import M8tes client = M8tes() # reads M8TES_API_KEY print("Starting your agent (the first reply can take up to a minute)...") # $1 API test credit — this model only until you top up or connect a provider for chunk in client.runs.stream_text( message="Draft a warm reply to a customer asking to cancel.", user_id="hello_world", model="deepseek-v4-1-flash", raise_on_error=True, ): print(chunk, end="", flush=True) ``` | If a run is blocked | Next step | |---|---| | `402 STARTER_LANE_MODEL_REQUIRED` | Use `deepseek-v4-1-flash`, or top up / connect a provider for other models. | | `402 TOKEN_BALANCE_DEPLETED` | Open `topup_url` or connect a model provider. | | `EMAIL_VERIFICATION_REQUIRED` | Verify email once you can run: up to 25 runs before verification. | Need the run id (to download files the agent wrote)? Use `runs.create(stream=True)` and read `stream.run_id` after the loop. `stream_text` yields only text chunks. ### Develop on your model subscription Prefer an external model provider for personal development (Hobby). Connect it, then run a matching model — that path does not spend prepaid credit. 1. Open [Account → Model providers](https://www.m8tes.ai/account). 2. Connect an external model provider and finish sign-in on the provider page. 3. Wait for **Connected**, then run with a matching model (example below uses Grok). Connecting activates Hobby on eligible trial/inactive accounts. Your usage limits and Hobby's run allowance still apply. Using a different provider? Follow [provider setup](/docs/models#use-your-own-provider-subscription) and choose a matching model.
Example: connect xAI in Python Run this once. Open the printed URL, enter the code, and leave the script running until it prints **Connected**. ```python import time from m8tes import M8tes client = M8tes() auth = client.model_connections.authorize("xai") print("Open:", auth.authorization_url) print("Enter code:", auth.user_code) deadline = time.monotonic() + 600 while auth.status == "pending": if time.monotonic() >= deadline: raise TimeoutError("Connection timed out. Run this script again.") time.sleep(max(auth.interval_seconds, 1)) auth = client.model_connections.authorization_status("xai", auth.state) if auth.status != "connected": raise RuntimeError("Connection failed: " + auth.status) print("Connected. Run the Grok example below.") ```
```python from m8tes import M8tes client = M8tes() # reads M8TES_API_KEY # Personal development only: this disables strict user_id checks account-wide. # This persists. For customer-facing apps, keep strict mode on and pass user_id. client.settings.update(require_end_user_id=False) # Connect a model provider first: https://www.m8tes.ai/account for chunk in client.runs.stream_text( message="Draft a follow-up for Acme's stalled renewal — SSO pricing and a Q3 start.", model="grok-4.6", # match whatever provider you connected raise_on_error=True, ): print(chunk, end="", flush=True) ``` Keep `user_id` out of personal development runs. [Billing details](/docs/billing-usage#develop-on-your-model-subscription). ### Production embeds use prepaid Same call as the first run: set `user_id` to your customer's id. These runs debit the prepaid wallet. New API accounts include **$1** test credit on `deepseek-v4-1-flash`; [top up](/docs/billing-usage#prepaid-balance) for the full catalog and sustained traffic. More: [Multi-tenancy](/docs/users).
HTTP equivalent ```bash read -rs -p "Choose a password: " M8TES_PASSWORD; echo curl -X POST https://api.m8tes.ai/api/v2/signup \ -H "Content-Type: application/json" \ -d "{\"email\": \"you@example.com\", \"password\": \"$M8TES_PASSWORD\", \"first_name\": \"yourname\"}" ``` ```bash curl -X POST https://api.m8tes.ai/api/v2/runs \ -H "Authorization: Bearer $M8TES_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message": "Draft a warm cancellation reply.", "user_id": "hello_world", "model": "deepseek-v4-1-flash", "stream": false}' ```
Next for Python: [Agents](/docs/agents) · [Runs](/docs/runs) · [Multi-tenancy](/docs/users) · [Testing](/docs/testing) (`MockM8tes`) ## TypeScript Backend agents in Node. Full surface: [TypeScript SDK](/docs/typescript-sdk). ### Install ```bash npm i @m8tes/sdk ``` Get a key from the [Developer dashboard](https://www.m8tes.ai/developer), then: ```bash export M8TES_API_KEY=m8_your_key_here ``` ### First run ($1 test credit) ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); console.log("Starting your agent (the first reply can take up to a minute)..."); const run = client.runs.create({ message: "Draft a follow-up for Acme's stalled renewal — SSO pricing and a Q3 start.", user_id: "hello_world", model: "deepseek-v4-1-flash", }, { raiseOnError: true }); for await (const chunk of run.iterText()) process.stdout.write(chunk); ``` ### Own model subscription Connect a provider at [Account → Model connections](https://www.m8tes.ai/account), then: ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); // Personal development only: this disables strict user_id checks account-wide. // This persists. For customer-facing apps, keep strict mode on and pass user_id. await client.settings.update({ require_end_user_id: false }); const run = client.runs.create({ message: "Draft a follow-up for Acme's stalled renewal — SSO pricing and a Q3 start.", model: "grok-4.6", // match whatever provider you connected }, { raiseOnError: true }); for await (const chunk of run.iterText()) process.stdout.write(chunk); ```
Example: connect xAI in TypeScript ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); let auth = await client.modelConnections.authorize("xai"); console.log("Open:", auth.authorization_url); console.log("Enter code:", auth.user_code); const deadline = Date.now() + 600_000; while (auth.status === "pending") { if (Date.now() >= deadline) throw new Error("Connection timed out. Run this script again."); await new Promise(resolve => setTimeout(resolve, Math.max(auth.interval_seconds, 1) * 1000)); auth = await client.modelConnections.authorizationStatus("xai", auth.state); } if (auth.status !== "connected") throw new Error("Connection failed: " + auth.status); console.log("Connected. Run the Grok example above."); ```
Production embeds: pass `user_id` and fund prepaid — see [Production embeds use prepaid](#production-embeds-use-prepaid). More: [TypeScript SDK](/docs/typescript-sdk) · [Streaming](/docs/streaming) · [Multi-tenancy](/docs/users). ## React / Next.js Drop a chat UI into your product. Full guide: [Embed a UI](/docs/embed-a-ui). ### Install ```bash npm i @m8tes/react ``` ### Drop in the chat Keep the `m8_` key on your server. In Next.js App Router: ```tsx // app/api/m8tes/[...path]/route.ts import { auth } from "@clerk/nextjs/server"; // or your existing server auth import { createM8tesHandler } from "@m8tes/react/server"; export const maxDuration = 300; // long-running streams export const { GET, POST } = createM8tesHandler({ apiKey: process.env.M8TES_API_KEY!, resolveUserId: async () => (await auth()).userId, // your signed-in user's id agentId: process.env.M8TES_AGENT_ID, // pin embed runs to one mate }); ``` ```tsx // app/assistant/page.tsx "use client"; import { M8tesProvider, MateChat } from "@m8tes/react"; import "@m8tes/react/styles.css"; export default function Assistant() { return ( ); } ``` The first message can take up to a minute while the agent's sandbox starts. Failed runs are logged by the route handler (`[m8tes] upstream …`) in your server log; your users see a neutral message. Next: [Embed a UI](/docs/embed-a-ui) · [Multi-tenancy](/docs/users) · [Human-in-the-Loop](/docs/human-in-the-loop) · [Community](/docs/community) # Use your coding agent Source: https://www.m8tes.ai/docs/build-with-agents Your coding agent (Claude Code, Cursor, or anything that reads URLs) can integrate m8tes for you. Paste an example prompt; it pulls the full docs corpus and writes the integration against your codebase. This page is about your agent doing the integration work, not the agents m8tes hosts. ## Example prompt Copy this into your coding agent. Swap the "What I want" block for your feature, keep the docs links and ground rules: ```text You are integrating the m8tes hosted agent SDK into this repo. m8tes runs agents as API resources (Python SDK, TypeScript SDK, React embed, REST). I write the prompt and pick the tools; m8tes runs the sandbox, memory, scheduling, approvals, and billing. Docs (read these first — prefer the corpus, then the stack page you need): - Full docs corpus: https://www.m8tes.ai/llms-full.txt - Quick start (Python / TypeScript / React·Next.js): https://www.m8tes.ai/docs/quickstart - Embed a UI (React / Next.js MateChat): https://www.m8tes.ai/docs/embed-a-ui - Multi-tenancy (user_id): https://www.m8tes.ai/docs/users - API reference: https://www.m8tes.ai/docs/api-reference - Errors: https://www.m8tes.ai/docs/api-errors - Custom tools / MCP: https://www.m8tes.ai/docs/custom-tools Ground rules: - Base URL: https://api.m8tes.ai/api/v2 (the /api/v2 prefix is required). - Auth: M8TES_API_KEY env var (m8_…). Never hardcode the key or paste it into chat. - Stack (pick what this repo uses): - Python: pip install -U "m8tes>=4.49.0" → from m8tes import M8tes - TypeScript/Node: npm i @m8tes/sdk → import { M8tes } from "@m8tes/sdk" - React/Next.js UI: npm i @m8tes/react → server route with createM8tesHandler, client MateChat inside M8tesProvider (see Embed a UI) - Every run belongs to a task. List a task's runs with client.runs.list(task_id=..., user_id="customer_123") (Python) or the TS equivalent. - After a run, gate on run.error_code before trusting run.output. - Multi-tenant product embeds: pass user_id on agents/runs/memories so each end user is isolated. Keep strict mode on for customer-facing apps. - Agents that talk to my end users should use prompt_profile="bare" so they do not brand as m8tes. What I want built (example — replace with my real feature): A CRM assistant inside our product. Each logged-in user gets an isolated agent (user_id = our customer id). They can ask "what's blocking Acme's renewal?", the agent uses HubSpot + Gmail when connected, streams the reply into our existing chat UI, and pauses for approval before sending outbound email. ``` ## Credentials Set the API key as an environment variable. Never paste it into a prompt or commit it: ```bash export M8TES_API_KEY=m8_your_key_here ``` Create or rotate a key with `POST /api/v2/token` or from the [Developer dashboard](https://www.m8tes.ai/developer). ## No account yet? Create one in Python; the key comes back immediately: ```python from getpass import getpass import m8tes result = m8tes.signup("you@example.com", getpass("Choose a password: "), "yourname") print(result.api_key) # export as M8TES_API_KEY ```
Or let the agent sign you up (passwordless HTTP flow) `POST /api/v2/signup` needs no auth. Omit `password` and the agent stays credential-free: ```bash curl -X POST https://api.m8tes.ai/api/v2/signup \ -H "Content-Type: application/json" \ -d '{"email": "alex@acme.com", "first_name": "alex"}' ``` The response contains a **setup-only API key**. The activation link is emailed to the person only; it is never in the API response. From there: 1. **Connect a model provider (local) or use the $1 test lane / top up (prod embeds), then set up.** For a cold hello-world, use the API starter credit on the pinned test model (see [Quick start](/docs/quickstart)). For personal development, [connect a matching model subscription](/docs/billing-usage#develop-on-your-model-subscription) and run without `user_id`. For production embeds, top up after the starter pin for the full catalog. Then use the key as a Bearer token to create agents and start runs. Unverified accounts may complete up to 25 runs before email verification is required. 2. **Detect the gate.** After that verification threshold, runs return `403` with `error.details.error_code == "EMAIL_VERIFICATION_REQUIRED"`. Tell the person to open the activation email; resend it with `POST /api/v2/verify/resend`. 3. **Hand off.** The person clicks the link, sets a password, and activates. At activation the setup-only key is **revoked**. The person mints their own key from the [Developer dashboard](https://www.m8tes.ai/developer) or `POST /api/v2/token`. 4. **Know when.** Poll `GET /api/v2/verify/status` with the setup key: `is_verified` flips to `true`. A `401` also means the hand-off happened (the key was revoked). `product` picks what to provision: `"api"` (default, the developer product) or `"platform"` (the team product, with Lead mate and Day-1 onboarding). `"api"` accounts start in [strict multi-tenant mode](/docs/users#strict-multi-tenant-mode); pass `"require_end_user_id": false` at signup for personal development only. This setting persists account-wide. For customer-facing apps, keep strict mode on and pass `user_id`. ## Run an agent with none of our branding An agent you embed in **your** product should sound like your product. By default an agent knows it runs on m8tes: it calls itself a mate, can describe what m8tes does, and knows our pricing. That is right for a team using m8tes directly and wrong for an agent your customers talk to. Set `prompt_profile` to `"bare"` and none of that reaches the agent: ```python agent = client.agents.create( name="Acme Support", instructions="You are Acme Support. Answer billing questions for Acme customers.", prompt_profile="bare", user_id="cust_42", ) ``` | | `"platform"` (default) | `"bare"` | |---|---|---| | Names m8tes / links m8tes.ai | yes | **no** | | Calls itself a "mate" | yes | **no** | | Knows our billing model | yes | **no** | | Can load m8tes' own Agent Skills | yes | **no** | | Your instructions, tools, and skills | unchanged | unchanged | | Capabilities, approvals, safety rules | unchanged | unchanged | `bare` removes branding, not ability. Permissions, approvals, memory, files, integrations and your own [Agent Skills](/docs/custom-skills) all work exactly the same. Switch an existing agent any time with `client.agents.update(id, prompt_profile="bare")`; it takes effect on that agent's next run. ### Turn off any built-in tool Every agent gets built-in tools (memory, task history, issue reporter, owner-notify email, desktop). Some are wrong for embeds: `report_issue` files feedback with **us**, and `notify` emails the account owner, not your end user. Name them in `disabled_builtin_tools` to unmount: ```python agent = client.agents.create( name="Acme Support", prompt_profile="bare", disabled_builtin_tools=["feedback", "notify", "computer_use"], user_id="cust_42", ) ``` Valid names: `GET /api/v2/built-in-tools`. Pass `teammate_id` to see what a run will actually mount after your disable list. Unknown names are rejected, so a typo cannot leave a tool on. ```python for t in client.built_in_tools.list(agent_id=agent.id).data: print(t.name, t.enabled) ``` ### See exactly what we inject Returns the real prompt the agent will run, rendered by the same code that runs it: ```python sp = client.agents.system_prompt(agent.id, user_id="cust_42") print(sp.prompt_profile, sp.characters) assert "m8tes" not in sp.system_prompt.replace("/home/daytona/m8tes-files/", "") ``` One deliberate exception: `/home/daytona/m8tes-files/` is the real sandbox directory your agent writes to when it shares a file, so that path survives in `bare`. It is a filesystem path the agent uses, never something it is told to say. Per-run context (memories, documents, connected tools, the triggering channel) is resolved when a run starts and is not included in this response. Why the hand-off is secure: | Property | Why it holds | |----------|--------------| | The agent never holds a login | Omit `password` and no credential exists for the agent to keep. | | The agent can't activate the account | The activation link is emailed to the person only; it is never in the API response. | | The agent hands off cleanly | When the person activates, the agent's setup-only key is revoked. The person mints their own key going forward. | | The agent can still start work now | Once you can run (top-up or own-subscription OAuth), you can complete up to 25 runs before email verification is required — connect or top up first; not free credit. | The blocked-run response: ```json { "error": { "type": "permission_error", "code": 403, "message": "Verify your email to keep running. Check your inbox for the activation link (resend: POST /api/v2/verify/resend).", "request_id": "req_...", "doc_url": "https://www.m8tes.ai/docs/api-errors#error-types", "details": { "error_code": "EMAIL_VERIFICATION_REQUIRED", "runs_used": 25, "verification_threshold": 25, "runs_remaining": 50, "balance_micros": 0, "balance_usd": "0.0000", "topup_url": "/billing/api" } } } ``` `runs_used` counts every run the account made, on any meter: it is the preview trip-wire, not a billing figure. The two allowances below it are **separate meters** and neither pays for the other. - `balance_micros` / `balance_usd`: the prepaid wallet (runs with `user_id`). Appears only on accounts with a prepaid balance. - `runs_remaining`: the platform plan (your own first-party runs: web app, and API calls with no `user_id`). A `user_id` run debits the balance and leaves `runs_remaining` untouched. On a new API account (strict multi-tenant by default), you will see `runs_remaining` sitting at the full plan allowance. See [Billing & usage](/docs/billing-usage).
## Good first asks - "add a per-user CRM assistant with HubSpot, stream replies into our Next.js chat UI" - "wire @m8tes/sdk so each customer_id maps to user_id and approvals pause outbound email" - "subscribe a webhook to run.completed and run.failed, and verify signatures" ## Verify the integration 1. Run the generated quickstart end to end. Confirm provider sign-in finishes and the run returns output. 2. Check `run.error_code` is empty on success paths, and that failures surface the code. 3. Confirm end-user isolation: a resource created with one `user_id` is invisible to another. Next: [Quick start](/docs/quickstart) · [Errors](/docs/api-errors) · [Webhook events](/docs/webhooks-guide) # Use cases Source: https://www.m8tes.ai/docs/use-cases Common patterns. Each recipe names the primitives it combines and links to the page that owns them. Working code lives in the SDK's [examples directory](https://github.com/m8tes-ai/m8tes/tree/main/sdk/py/examples), with React embed examples under [packages/react/examples](https://github.com/m8tes-ai/m8tes/tree/main/packages/react/examples). ## Internal team automation Recurring ops work: weekly reports, ticket review, revenue monitoring. Combine an [agent](/docs/agents) with tools (Stripe, Slack, Gmail), a [scheduled task](/docs/scheduling), and [file output](/docs/files). One scheduled task covers most of it: ```python task = client.tasks.create( agent_id=reporter.id, instructions="pull last week's metrics and write weekly_report.md", schedule="0 9 * * 1", schedule_timezone="America/New_York", user_id="customer_42", ) ``` Full pipelines: `examples/file-report.py` (streaming progress, file tracking, download) and `examples/support-triage.py` (real-time event filtering). ## Browser and desktop automation Reach things with no API: legacy dashboards, GUI apps, sites behind a login. Every sandboxed run gets a full Linux desktop the agent drives from screenshots. Computer use is on automatically; list only the integrations the run needs in `tools`. See [Computer Use](/docs/computer-use). ## Embed in your product Run agents on behalf of your users. Combine [multi-tenancy](/docs/users) (memory, history, and tool connections isolated per `user_id`), per-user OAuth for integrations like Gmail and Slack, [streaming](/docs/streaming) for a live chat UI, and the drop-in [React component](/docs/embed-a-ui). Per-user OAuth flow with connection guards: `examples/embed-oauth.py`. ## Plan and approve Gate tool use behind approval: interactively in a terminal, automatically with an `on_approval` callback (allow safe tools, deny the rest), or async via [webhook events](/docs/webhooks-guide) answered through the API. See [Human-in-the-Loop](/docs/human-in-the-loop) for the modes and the webhook flow; interactive and automated versions in `examples/plan-mode.py`. ## Generate reports and files Agents write CSV, JSON, and Markdown files you list and download after the run (`client.runs.list_files`, `client.runs.download_file`). Combine with [scheduling](/docs/scheduling) for automated weekly pipelines. See [Files](/docs/files); complete version in `examples/file-report.py`. Next: [Quick Start](/docs/quickstart) · [Agents](/docs/agents) # vs. frameworks Source: https://www.m8tes.ai/docs/vs-frameworks A framework (eve, LangGraph, CrewAI, OpenAI Agents SDK) is code you build, host, and operate. The m8tes API is a managed platform: agents are API resources; the infrastructure is operated for you. **A framework gives you a codebase to build an agent. m8tes gives you an API to give every customer one.** ## Choose a framework Use a framework when the agent's behavior is your core product and you want to own the code and infra. [eve](https://eve.dev), LangGraph, and the OpenAI Agents SDK are strong choices. ## Choose m8tes Use m8tes when you want agents in your product without building agent infrastructure. You write the prompt and pick the tools. m8tes manages: | | | |---|---| | **Multi-tenancy** | Every end user gets an isolated agent: memory, history, files, tool access, and spend scoped by `user_id`. [Users](/docs/users) | | **Hosted runtime** | Each run executes in an isolated sandbox. Nothing to deploy or scale. | | **Integrations** | 190+ managed integrations with OAuth handled, including end users connecting their own accounts. [Tools](/docs/tools) | | **Custom tools** | Bring your own MCP servers, custom tools, and skills. [Custom Tools](/docs/custom-tools) | | **Model choice** | Model-agnostic: pick the model per agent or per run. [Models](/docs/models) | | **Memory** | Persistent per-user memory across runs. [Memories](/docs/memories) | | **Scheduling and triggers** | Cron schedules, webhooks, email inboxes, Slack, and iMessage (private beta). [Scheduling](/docs/scheduling) | | **Human-in-the-loop** | Three permission modes; approvals answered in email or Slack. [Human-in-the-Loop](/docs/human-in-the-loop) | | **Streaming** | Real-time run events over SSE. [Streaming](/docs/streaming) | | **Webhook events** | Your product is notified when runs complete or need approval. [Webhook Events](/docs/webhooks-guide) | | **Files and reports** | Agents generate files, data exports, and reports. [Files](/docs/files) | | **Computer use** | Agents operate a full desktop for apps without an API. [Computer Use](/docs/computer-use) | | **Embedded UI** | Drop-in React chat component, `@m8tes/react`. [Embed a UI](/docs/embed-a-ui) | | **Billing and limits** | Per-token billing, per-user spend caps, zero-data-retention option. [Billing & Usage](/docs/billing-usage), [Data Retention](/docs/data-retention) | ## Both The two compose: some teams build a bespoke core agent on a framework and use m8tes for the per-customer fleet. Next: [Quick Start](/docs/quickstart) · [Users](/docs/users) · [Embed a UI](/docs/embed-a-ui) # Community Source: https://www.m8tes.ai/docs/community Get help with the SDK, CLI, and API. | Need | Go to | |---|---| | Report a bug or request a feature | [GitHub Issues](https://github.com/m8tes-ai/m8tes/issues) | | Ask a question or share a project | [Discussions](https://github.com/m8tes-ai/m8tes/discussions) | | Reproduce a bug locally | [Contributing guide](https://github.com/m8tes-ai/m8tes/blob/main/.github/CONTRIBUTING.md) | | Private / billing help | [support@m8tes.ai](mailto:support@m8tes.ai) | We review issues weekly. Prefer a Discussion for "how do I…?" and an Issue for a reproducible bug. External PRs are not accepted: the public SDK repo syncs from our private monorepo. ## Docs feedback Use the thumbs below any docs page to flag missing or confusing information. ## Sales / white-label For custom branding, dedicated channels, or integration help: [sales@m8tes.ai](mailto:sales@m8tes.ai). ## Changelog Product releases: [CHANGELOG.md](https://github.com/m8tes-ai/m8tes/blob/main/CHANGELOG.md) in the public SDK repo (and the product changelog in this monorepo). Python SDK versions: PyPI `m8tes`. Next: [Quick Start](/docs/quickstart) · [Errors](/docs/api-errors) · [Going Live](/docs/going-live) # Agents Source: https://www.m8tes.ai/docs/agents An **agent** is a reusable definition: a persona with instructions and tools. Configure it once, then trigger runs as often as you need. > The API resource was previously named "teammates". `/api/v2/teammates` and `client.teammates` remain permanent aliases. ## Create an agent ```python from m8tes import M8tes client = M8tes() bot = client.agents.create( name="support agent", instructions="review incoming support requests and draft replies", tools=["gmail", "linear"], default_permission_mode="approval", user_id="customer_123", ) ``` ### Key fields | Field | Type | Description | |-------|------|-------------| | `name` | string | Agent name. Auto-generated if omitted | | `instructions` | string | Core behavior and context | | `tools` | string[] | Allowed tools for this agent | | `default_permission_mode` | string | `autonomous`, `approval` (default), or `plan`; used for direct runs and new task defaults | | `model` / `effort` | string | Model and reasoning depth. Omit for platform defaults. See [Models](/docs/models) | | `user_id` | string | End-user scope. See [Users](/docs/users) | | `group_id` | int | Optional mate Group. See [API Reference → Groups](/docs/api-reference#groups) |
All fields | Field | Type | Description | |-------|------|-------------| | `role` | string | Optional persona role | | `goals` | string | Optional goals injected into prompt context | | `email_inbox` | bool | Enable email inbox at creation. Returns `email_address` immediately | | `webhook` | bool | Enable webhook trigger at creation. Returns `webhook_url` once | | `allowed_senders` | string[] | Allowed addresses or `@domain` patterns for email triggers | | `inbound_imessage_enabled` | bool | Enable inbound Apple Messages routing for this agent | | `imessage_chat_guid` | string | BlueBubbles chat GUID used to route inbound iMessage and send replies | | `enable_self_improvement` | bool | Weekly review-and-improve task. See [Self-improvement](#self-improvement) |
## Trigger runs An agent can be triggered four ways: a direct message, inbound email, inbound iMessage, or webhook. Direct runs inherit the agent's `default_permission_mode` unless the run overrides `permission_mode`. ### Message ```python run = client.runs.create_and_wait(agent_id=bot.id, message="review all unread support emails from today", user_id="customer_123") print(run.output) ``` Streams by default too; see [Runs](/docs/runs) for the event loop. ### Email inbox ```python # email_inbox=True generates a unique @notifications.m8tes.ai address for the agent bot = client.agents.create(name="support bot", email_inbox=True, user_id="customer_123") print(bot.email_address) # emails sent here start a run ``` ### Webhook ```python bot = client.agents.create(name="ops bot", webhook=True, user_id="customer_123") print(bot.webhook_url) # POST {"message": "run now"} here to trigger a run ``` | Webhook operation | Token behavior | |---|---| | Create or `enable_webhook()` | Full URL shown once; enabling again rotates the token | | Read | URL masked; `null` means no webhook | | `set_webhook_enabled(agent_id, enabled=False)` | Pauses delivery; preserves the URL | | `disable_webhook()` | Destroys the token |
iMessage (private beta) iMessage is in **private beta** — not generally available, and not self-serve for API developers. `bridges.provision` / `bridges.create` return `403 feature_disabled`. Dedicated per-app numbers are a setup we do with you (see [iMessage inbox](/docs/imessage-inbox)). For production, prefer email or webhooks for now. ```python bot = client.agents.create( name="messages bot", inbound_imessage_enabled=True, bridge_id=bridge.id, # from client.bridges.provision(). See iMessage inbox imessage_chat_guid="iMessage;-;+15551231234", user_id="customer_123", ) ``` Requires BlueBubbles on your account. Use a dedicated 1:1 chat unless everyone in that thread should trigger the agent and receive its replies.
## Self-improvement Set `enable_self_improvement=True` and the agent gets better at its job on its own: once a week it reviews its recent runs and, where it finds a clear, durable problem, rewrites its own instructions, reshapes its tasks, and records lessons for next time. ```python client.agents.create(name="support agent", instructions="answer billing questions from the docs", enable_self_improvement=True, user_id="customer_123") ```
How the weekly review behaves - It only changes things when the runs warrant it. A good week means no changes. - Reversible edits apply on their own; higher-stakes moves (disabling a task, connecting an integration) are surfaced for a human to approve, not done automatically. - Enabling it also turns on the task-setup, history, and memory tools the review needs. - It runs as a normal scheduled task on the agent, so it's billed like any other run. - With [end-user scoping](/docs/users), each end-user's agent only reviews and learns from that end-user's own runs.
## Manage agents ```python # Multi-tenant accounts are strict by default, so scope the list to one end-user. # Without user_id a strict account gets a 422 rather than every end-user's agents. for agent in client.agents.list(user_id="customer_123").data: print(agent.id, agent.name) agent = client.agents.get(bot.id, user_id="customer_123") client.agents.update(bot.id, instructions="review support requests, prioritize P1 incidents, draft clear replies", user_id="customer_123") client.agents.disable(bot.id, user_id="customer_123") # pause: schedules stop firing, agent stays listed; enable() re-arms them client.agents.delete(bot.id, user_id="customer_123") # archive ``` ## Organize and share Teams The product calls these **Teams**; the API resource remains `groups`, so existing integrations keep stable routes and field names. Teams can nest to match how work is owned. Create Marketing → Paid Ads → Google by passing each parent's id: ```python marketing = client.groups.create(name="Marketing", user_id="customer_123") paid_ads = client.groups.create(name="Paid Ads", parent_id=marketing.id, user_id="customer_123") google = client.groups.create(name="Google", parent_id=paid_ads.id, user_id="customer_123") client.agents.update(bot.id, group_id=google.id, user_id="customer_123") ``` ```ts const marketing = await client.groups.create({ name: "Marketing", user_id: "customer_123" }); const paidAds = await client.groups.create({ name: "Paid Ads", parent_id: marketing.id, user_id: "customer_123" }); const google = await client.groups.create({ name: "Google", parent_id: paidAds.id, user_id: "customer_123" }); await client.agents.update(bot.id, { group_id: google.id, user_id: "customer_123" }); ``` Each returned group includes `parent_id`, a root-to-parent `path` that excludes the group itself, and `can_manage` / `can_leave` for the current user. A shared subtree may hide ancestors you cannot see; in that case its visible root has `parent_id: null` and an empty `path`. Pass `parent_id=None` in Python or `parent_id: null` in TypeScript to move a Team to the root. Omit it to keep the current parent. Deleting a Team recursively deletes its child folders and ungroups their mates. ### Invite members with a role An invitation grants one role on that Team and all of its descendants. New invitations default to `editor`; grants created before roles were introduced remain `viewer`. | Role | Rights in the shared subtree | |---|---| | `viewer` | Read Teams, mates, tasks, runs and results, documents, artifacts, and other work | | `runner` | Everything a viewer can read, plus run or chat with mates and reply to, cancel, or approve their runs | | `editor` | Everything a runner can do, plus edit mates, tasks, documents, and work in the shared subtree | This is scoped hierarchy sharing, not organization membership. An editor cannot invite people, grant roles, self-escalate, reorganize Teams, or move a mate out of the shared scope. Only the Team owner or an organization admin manages membership and roles. Moving a Team or revoking a direct membership changes inherited access immediately. If a private mate is currently running, wait for it to finish or stop it before accepting an invitation, upgrading access, or moving it into a shared Team. These actions return a validation error until the private run stops; existing shared runs can continue. Execution intentionally uses the shared mate owner's bound tools and billing. The human requester is audited separately for each action. Members can use the owner's configured integrations through the mate, but credentials and secret values remain opaque. Shared execution retains the mate's own memory and documents while excluding private account and company context. Native shell, file-reading, and desktop tools are currently restricted because reused sandboxes can retain owner credentials. Mate documents can still be read and edited through scoped tools using inline content; importing a document from an arbitrary sandbox path is unavailable. Shared coding and arbitrary local file workflows require additional runtime isolation before they are fully supported. Shared sandbox downloads and artifact promotion accept regular output files up to 10,000,000 bytes and reject symbolic links. Download format conversion uses the same verified file content. Mate responses expose the effective controls directly: can_manage for editing, can_execute for running, and owner-only can_share for sharing controls. Use these fields instead of inferring rights from the role label. ```python invite = client.groups.invite(google.id, email="ada@example.com", role="runner") members = client.groups.members(google.id).data for member in members: source = member.inherited_from_group_name if member.inherited else "direct" print(member.email, member.role, source, member.can_remove) # The manager can withdraw an invitation while it is still pending. # client.groups.cancel_invite(invite.id) ``` Ada opens the emailed link with her own authenticated client. Invitation tokens are bearer secrets, so pass the token from the link directly and do not log or store it. ```python preview = recipient_client.groups.preview_invite(token_from_email) if preview.valid and preview.matches_current_user and not preview.requires_verification: print(f"Accepting the {preview.role} role") joined = recipient_client.groups.accept_invite(token_from_email) ``` After acceptance, the manager refetches members before changing or removing Ada's direct grant. Change an inherited role at its source Team. ```python members = client.groups.members(google.id).data ada = next( (candidate for candidate in members if candidate.email == "ada@example.com" and not candidate.inherited), None, ) if ada is None: print("Ada has not accepted the invitation yet") else: client.groups.update_member(google.id, ada.member_id, role="editor") client.groups.remove_member(google.id, ada.member_id) ``` ```ts const invite = await client.groups.invite(google.id, { email: "ada@example.com", role: "runner", }); for (const member of (await client.groups.members(google.id)).data) { console.log(member.email, member.role, member.inherited_from_group_name, member.can_remove); } // The manager can withdraw an invitation while it is still pending. // await client.groups.cancelInvite(invite.id); ``` Ada accepts from her own authenticated client. The manager then refetches the member list before managing her direct grant. ```ts const preview = await recipientClient.groups.previewInvite(tokenFromEmail); if (preview.valid && preview.matches_current_user && !preview.requires_verification) { console.log("Accepting the " + preview.role + " role"); await recipientClient.groups.acceptInvite(tokenFromEmail); } const acceptedMembers = (await client.groups.members(google.id)).data; const ada = acceptedMembers.find((member) => member.email === "ada@example.com" && !member.inherited); if (ada) { await client.groups.updateMember(google.id, ada.member_id, { role: "editor" }); await client.groups.removeMember(google.id, ada.member_id); } ``` Member and invitation endpoints are account-scoped and do not take `user_id`; group CRUD keeps `user_id` for embedded end-user isolation. Accepting requires the authenticated account's verified email to match the invitation, case-insensitively and without alias folding. Only managers can list members or invitations or change a direct member's role. A manager, or any directly granted member removing their own access, can remove a direct membership. An inherited member row reports the role and source Team but must be changed or removed at that source. `client.groups.share(group_id, visibility="organization")` remains a legacy bulk operation over the mates directly inside one group. It changes those mates' personal/organization visibility and is independent of recursive Team access; an existing organization or public grant continues after a Team grant is revoked. ## Documents Agents maintain persistent documents across runs: polished deliverables like `latest-report`, as opposed to per-run files. Agents write content; your application can list, read, rename, summarize, and delete it: ```python page = client.documents.list(scope="teammate", agent_id=bot.id, user_id="customer_123") for doc in page.data: print(doc.name, doc.summary) ``` ```python report = client.documents.get(7, user_id="customer_123") client.documents.update(7, name="weekly-report", summary="Current weekly PPC report", user_id="customer_123") client.documents.delete(7, user_id="customer_123") ``` Use `scope="company"` for documents available across agents. Pass `user_id` on every method to keep embedded end-users strictly isolated. The agent-nested read methods remain available for reading an agent document by name. Next: [Runs](/docs/runs) · [Tasks](/docs/tasks) · [Models](/docs/models) · [Tools](/docs/tools) # Tasks Source: https://www.m8tes.ai/docs/tasks A **task** is saved work attached to an agent. Use tasks when you want repeatable instructions that can run on demand or by trigger. ## Create a task ```python from m8tes import M8tes client = M8tes() task = client.tasks.create( agent_id=1, name="daily support review", instructions="review unread support emails, rank priority, and draft replies", user_id="customer_123", ) ``` ```bash curl -X POST https://api.m8tes.ai/api/v2/tasks \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"teammate_id": 1, "name": "daily support review", "instructions": "review unread support emails, rank priority, and draft replies", "user_id": "customer_123"}' ``` > `teammate_id` is the wire name for the agent id: JSON request and response bodies keep it. The SDK accepts `agent_id` (canonical) and `teammate_id` alike. ### Key fields | Field | Type | Description | |-------|------|-------------| | `teammate_id` | int (required) | Agent that executes the task | | `instructions` | string (required) | What should happen when the task runs | | `name` | string | Task label | | `schedule` | string | Cron expression, e.g. `0 9 * * 1-5`. Attaches trigger immediately | | `webhook` | bool | Enable webhook trigger at creation. Returns `webhook_url` once | | `user_id` | string | End-user scope. See [Users](/docs/users) | | `workflow_stage` | string | Optional board position: `unassigned`, `pending`, `planning`, `queue`, `review`, or `done` |
All fields | Field | Type | Description | |-------|------|-------------| | `tools` | string[] | Optional override of agent tools | | `expected_output` | string | Output format expectation | | `goals` | string | Task-specific goals injected into prompt context | | `schedule_timezone` | string | Timezone for `schedule`. Defaults to `UTC` | | `enable_lessons` | bool | Accumulate lessons from runs. Defaults to `true`. See [Lessons](#lessons) | `webhook_enabled` is `true` when a webhook trigger is active. `webhook_url` is only set on first creation when `webhook=True`; it's not retrievable afterwards.
## Run a task ```python with client.tasks.run(task.id, user_id="customer_123") as stream: for event in stream: if event.type == "text-delta": print(event.delta, end="", flush=True) ```
Approval callbacks, non-streaming runs, tool scoping `run_and_wait()` handles approval pauses inline, no polling loop needed. It accepts the same callbacks as `runs.create_and_wait()`; see [Human-in-the-Loop](/docs/human-in-the-loop). ```python run = client.tasks.run_and_wait( task.id, on_approval=lambda req: "allow", on_question=lambda req: {"Which channel?": "#general"}, user_id="customer_123", ) print(run.output) ``` Non-streaming: ```python run = client.tasks.run(task.id, stream=False, user_id="customer_123") run = client.runs.poll(run.id, user_id="customer_123") print(run.output) ``` Pass `task_setup_tools=False` to keep a run limited to the task's normal tools, skipping the internal same-scope management tools for tasks, runs, inboxes, webhooks, and integrations. Pass `feedback=False` to disable the internal issue-reporting tool (`report_issue`).
## Scheduling Pass `schedule=` at creation time to attach a cron trigger in the same call: ```python task = client.tasks.create( agent_id=1, instructions="send weekly revenue report to #finance", schedule="0 9 * * 1-5", # weekdays at 9:00 schedule_timezone="America/New_York", # defaults to UTC user_id="customer_123", ) ``` See [Scheduling](/docs/scheduling) for cron patterns, interval schedules, and pausing or reshaping schedules. List every trigger across the account without fetching each task separately: ```python for trigger in client.triggers.list(type="schedule", user_id="customer_123").auto_paging_iter(): print(trigger.task_id, trigger.id, trigger.enabled) ``` `client.triggers.list()` includes schedule, webhook, email, and app triggers, including disabled ones. The legacy V1 schedules list returned only enabled schedules. ## Webhook trigger ```python task = client.tasks.create(agent_id=1, instructions="process incoming data", webhook=True, user_id="customer_123") print(task.webhook_url) # POST here to trigger a run (shown once, save it) ``` Add one to an existing task with `client.tasks.triggers.create(task.id, type="webhook")`; the returned `trigger.url` is likewise shown once. ## Retrieve a task's results Every run reports its task (`run.task_id`) and `runs.list` filters by it, so a scheduled or webhook-triggered task's history and outputs are one call away: ```python latest = client.runs.list(task_id=task.id, user_id="customer_123", status="completed", limit=1).data if latest: print(client.runs.get(latest[0].id, user_id="customer_123").output) ``` Failed runs carry a machine-readable `error_code` plus a human-readable `output`; `retryable=True` means `client.runs.retry(run.id)` will be accepted. Prefer push? Subscribe a [webhook endpoint](/docs/webhooks-guide) to `run.completed` / `run.failed`. ## Lessons Agents save **lessons** from a task's runs: durable corrections and preferences they apply on future runs, accumulated automatically (capped at 20 per task; disable with `enable_lessons=False`). The API is the curation surface: read what the agent learned, remove bad entries. ```python for lesson in client.tasks.lessons(task.id).data: print(lesson.id, lesson.text, lesson.when_applicable) client.tasks.delete_lesson(task.id, lesson_id="...") # or clear_lessons(task.id) for all ``` ## Manage tasks ```python for item in client.tasks.list(agent_id=1, user_id="customer_123").data: print(item.id, item.name) client.tasks.update(task.id, instructions="review unread emails, escalate urgent incidents, and draft replies", user_id="customer_123") client.tasks.delete(task.id, user_id="customer_123") ``` Next: [Runs](/docs/runs) · [Scheduling](/docs/scheduling) · [Human-in-the-Loop](/docs/human-in-the-loop) · [Webhook Events](/docs/webhooks-guide) # Runs Source: https://www.m8tes.ai/docs/runs A **run** is one execution of an agent or task. Use runs for direct messages, follow-ups, and real-time output. Run list and detail responses include `needs_reply` when a completed turn asks for a concrete replyable decision (approve, answer, connect). Show these runs as **Needs you**, even after they have been viewed or emailed. Soft FYI / out-of-reach notes must not set `needs_reply`. Agents choose delivery when awaiting: Slack for one quick decision, email for deep/batched asks, or in-app only (`channel=none`) when the user is already in the run — unattended runs (schedule, webhook, Day-1) must interrupt via Slack or email (`none` + `needs_reply` is rejected there). Send the answer with `runs.reply()`; viewing the run does not answer the ask. On the Platform board, Archive clears a reply-request run from Needs you in one tap (Undo during the exit animation). A newer completed scheduled run of the same task (and `user_id`) supersedes it, so recurring tasks do not stack stale asks. `closing_preview` is a short headline for cards; `latest_message_preview` contains up to 600 characters from the latest assistant message for hover previews. On a finished run, `files` is the completion-time output list (`[{name, size, label}]`) when the server stamped a manifest — `null` means call `runs.list_files()`; `[]` means none. `label` is an optional human name for the file (a report's `title:`); downloads always use `name`. Bytes still come from `runs.download_file`. ## Create a run ### Streaming (default and recommended) Use the context manager so the stream closes cleanly even if you exit early: ```python from m8tes import M8tes client = M8tes() with client.runs.create(agent_id=1, message="summarize this week's support trends", user_id="customer_42") as stream: for chunk in stream.iter_text(): print(chunk, end="", flush=True) print(stream.text) # full accumulated output ``` ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); // reads M8TES_API_KEY const stream = client.runs.create({ agent_id: 1, message: "summarize this week's support trends", user_id: "customer_42", }); for await (const chunk of stream.iterText()) process.stdout.write(chunk); console.log(stream.output); // full accumulated output ``` See [Streaming & Events](/docs/streaming) for event types, text-only streaming, and reconnection, or pass `stream=False` and poll: ```python run = client.runs.create(agent_id=1, message="summarize this week's support trends", stream=False, user_id="customer_42") run = client.runs.poll(run.id, user_id="customer_42") print(run.output) ``` ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); const run = await client.runs.createAsync({ agent_id: 1, message: "summarize this week's support trends", user_id: "customer_42", }); // get() reads ONCE. The run may still be running. createAndWait() polls for you. const done = await client.runs.get(run.id, { user_id: "customer_42" }); console.log(done.status, done.output); // Stop a run that is still going: const cancelled = await client.runs.cancel(run.id, { user_id: "customer_42" }); console.log(cancelled.status); // "cancelled" ``` ```bash curl -X POST https://api.m8tes.ai/api/v2/runs \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"teammate_id": 1, "message": "summarize this week", "stream": false, "user_id": "customer_42"}' ``` > `teammate_id` is the wire name for the agent id: JSON request and response bodies keep it. The SDK accepts `agent_id` (canonical) and `teammate_id` alike. ### Run options | Field | Type | Default | Description | |-------|------|---------|-------------| | `message` | string (required) | n/a | Prompt or instruction for the run | | `teammate_id` | int | n/a | Existing agent to execute | | `permission_mode` | string | agent default | Override with `autonomous`, `approval`, or `plan`. See [Human-in-the-Loop](/docs/human-in-the-loop) | | `user_id` | string | n/a | End-user scope. See [Users](/docs/users) | | `output_schema` | object | n/a | JSON Schema for a typed result. See [Structured output](#structured-output) |
All run options | Field | Type | Default | Description | |-------|------|---------|-------------| | `stream` | bool | `true` | Stream events in real-time | | `tools` | string[] | n/a | Override agent tools for this run | | `files` | list | n/a | Input files to upload. See [Files](/docs/files) | | `memory` | bool | `true` | Include saved per-user memory | | `history` | bool | `true` | Include prior run context | | `human_in_the_loop` | bool | inherited | Omit to inherit `false` for autonomous and `true` for approval/plan | | `task_setup_tools` | bool | `true` | Enable the internal same-scope agent, task, run, webhook, inbox, and app-management tools | | `feedback` | bool | `true` | Enable the internal issue-reporting tool (`report_issue`) | | `email_inbox` | bool | `false` | Enable email inbox on the auto-created agent (only when no `teammate_id` is given). The response includes `email_address`; emails there trigger future runs | Replies inherit `task_setup_tools` and `feedback` unless overridden.
## Follow up on a run A follow-up continues the **same run**: it re-opens the run, keeps the prior context, and does not consume a new run-count slot (it reuses the original run id and only burns tokens). For a failed or cancelled run, `can_continue` indicates that it has a saved, unexpired conversation eligible for `runs.reply()`. Prefer Continue when this is true; a run that never started needs its original task retried. Reply still checks current funding and permissions. For an explicit continuation, pass `reset_auto_recovery=True` in Python or `{ reset_auto_recovery: true }` in TypeScript. This grants a fresh three-attempt recovery budget when the reply is accepted, including when it is queued. Automatic loops should leave this option false. `auto_recovery_attempt_count` reports attempts in the current budget; `auto_retry_count` preserves the cumulative history. A later platform update can then recover work you explicitly continued, even if an earlier recovery budget was exhausted. ```python first = client.runs.create_and_wait(agent_id=1, message="find all open incidents", user_id="customer_42") with client.runs.reply(first.id, message="now draft an update for each owner") as stream: for chunk in stream.iter_text(): print(chunk, end="", flush=True) ``` ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); const first = client.runs.create({ agent_id: 1, message: "find all open incidents", user_id: "customer_42" }); await first.text(); // drain the first turn; runId is set by then const followUp = client.runs.reply(first.runId!, "now draft an update for each owner"); for await (const chunk of followUp.iterText()) process.stdout.write(chunk); ``` `runs.reply()` inherits the run's settings: permission mode keeps applying, and AskUserQuestion stays enabled on runs created with `human_in_the_loop: true`. Pass `human_in_the_loop: false` on the reply to pin non-interactive behavior. ### Replying while the run is still executing A reply works on a live run too. If the run's current turn is still executing, the message is **queued** and delivered as the run's next turn the moment the current one ends. Never a second concurrent execution. The response tells you which happened: ```python run = client.runs.reply(42, message="also check the staging env", stream=False) if run.delivery == "queued": # run was mid-turn; message delivers next print("queued as message", run.queued_message_id) else: # "resumed": it became the next turn now print("resumed") ``` With `stream=True` on a queued reply, the SDK joins the live run's ongoing stream (the queued message itself delivers on the next turn). Delivery normally starts within seconds of the turn ending; across a platform update it can take up to ~15 minutes (the recovery sweep). Every queued message resolves observably: `run.message_received` when queued, then either `run.started` for its delivery turn or `run.message_cancelled` with a reason (owner cancelled [agent senders only; your own replies to a run you cancelled still deliver and revive it], sandbox expired, account disabled, or 24h undelivered). While the run is live, replies with file attachments or per-reply overrides (`tools`, `permission_mode`, `task_setup_tools`, `feedback`, `human_in_the_loop`) return `409`. Overrides only apply to an immediate resume; silently dropping them would be worse. This is also how **agent-to-agent messaging** behaves: when one of your agents messages another agent's run, the message arrives the same way. Queued if the target is busy, delivered as the next turn, and always attributed to the sending agent, never to you. ## Structured output Pass a JSON Schema as `output_schema` and the run returns typed data on `output_data`, so you can act on fields instead of parsing prose. ```python run = client.runs.create_and_wait( agent_id=1, message="Review this Sentry issue and rate its severity.", output_schema={"type": "object", "properties": {"severity": {"type": "string", "enum": ["low", "medium", "high"]}}}, user_id="customer_42", ) if run.output_data: # always None-check print(run.output_data["severity"]) # -> "high" ```
Schema rules and gotchas - The schema root must be `"type": "object"`, so `output_data` is always an object. - **Inline your definitions.** `$ref` and `$defs` are rejected. Pydantic's `model_json_schema()` and zod both emit `$defs` for nested models, so flatten before sending. - **`output_data` can be `null` on a completed run.** A run cut short by truncation, a pause, or a spend limit still completes, with its text `output` intact but no structured result. Always null-check. - The schema sticks to the run: replies, resumes, and retries stay structured without re-sending it. - `run.completed` webhooks carry `output_data` too.
## Run outcome `GET /runs/{id}/outcome` returns the condensed result of a run in one call (the agent's closing message, the structured result, and what the run cost) instead of the full transcript. ```python outcome = client.runs.outcome(run_id=42) print(outcome.summary, outcome.cost_usd) # "Audit done: paused 3 wasteful keywords." "0.4831" if outcome.needs_reply: # the agent is asking for a decision client.runs.reply(42, message="Approve both") if outcome.delivery_channel == "none": # agent chose silence. No completion email pass ``` ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); const outcome = await client.runs.outcome(42); console.log(outcome.summary, outcome.cost_usd); // "Audit done: paused 3 wasteful keywords." "0.4831" if (outcome.needs_reply) { // the agent is asking for a decision await client.runs.reply(42, "Approve both").text(); } if (outcome.delivery_channel === "none") { // agent chose silence. No completion email on scheduled runs } ```
Outcome fields | Field | Meaning | |-------|---------| | `summary` | The agent's closing message. `None` when the run ended on a tool call | | `headline` | One-line outcome descriptor supplied by the agent, when present | | `needs_reply` | `True` when the closing message asks for a decision. Answer via `runs.reply()` | | `needs_reply_count` | When several decisions are waiting, how many (may be `null`) | | `delivery_channel` | `email`, `slack`, or `none`. Set by the agent via `set_run_delivery` during the run | | `output_data` | Structured result matching the run's `output_schema` | | `cost_usd` | Metered cost as a decimal string. `None` until cost is recorded | | `message_count`, `input_tokens`, `output_tokens`, `total_tokens` | Run-level usage metrics |
## Scheduled delivery (`set_run_delivery`) On scheduled and webhook-triggered runs, the agent chooses how the outcome reaches the owner by calling the **`set_run_delivery`** built-in tool before its closing message: | `channel` | Meaning | Completion email | |-------------|---------|------------------| | `email` | Deep work (report, audit). Short closing hook; depth in `latest-report` PDF | Sent when the task has `email_notifications: true` | | `slack` | Quick 1–3 line nudge | Suppressed (first-party only: agent also Slacks via `send_slack_message_to_user`) | | `none` | All clear. Nothing worth interrupting | Suppressed | Read the choice from `runs.outcome().delivery_channel` or `run.completed` webhook `data.delivery_channel`. Disable the tool with `disabled_builtin_tools: ["delivery"]` on the agent. Embed agents (`prompt_profile: "bare"`) do not receive m8tes's delivery playbook in the system prompt. Document `set_run_delivery` in your agent `instructions` if scheduled runs should use it. ```python run_id = 1 outcome = client.runs.outcome(run_id) if outcome.delivery_channel == "email" and outcome.needs_reply: notify_user(outcome.summary, subject=outcome.headline) elif outcome.delivery_channel == "none": logger.info("scheduled run chose silence run_id=%s", run_id) ``` Legacy `NEEDS-REPLY` / `HEADLINE:` marker lines in closing prose are deprecated; `delivery_channel`, `needs_reply`, and `headline` on outcome and webhooks are authoritative. `run.status` values: `running`, `paused`, `awaiting_approval` (waiting for user input or approval), `completed`, `failed`, `cancelled`, `closed`, `archived`. ## Results nobody has opened A run you start yourself and walk away from comes to you. Pass `email_notifications=True` on `runs.create()` (the m8tes app does this for the runs it starts). If nobody has viewed the finished run 5 minutes after it lands, its closing message is sent to the account owner: a Slack DM when Slack is connected, else email. A reply continues the run. - **Viewed** means `runs.mark_viewed(run_id)` (`POST /runs/{id}/view`). The m8tes app calls it while the run is on screen. - **The agent decides what is worth sending.** `set_run_delivery(channel="none")` without an ask keeps a result in the dashboard. An open ask (`needs_reply`) is always sent. - **`run.notified_at` / `run.notified_channel`** say when and where it went (`email` or `slack`). - **First-party runs only.** A run scoped to one of your end-users (`user_id`) is never sent to you. A saved task's run over the API is not sent either, only its scheduled completions. - **Turn it off for yourself** with `client.settings.update(result_email_enabled=False, result_slack_enabled=False)`, or Settings → Preferences in the app. ```python # Personal development only: this disables strict user_id checks account-wide. # This persists. For customer-facing apps, keep strict mode on and pass user_id. client.settings.update(require_end_user_id=False) run = client.runs.create( agent_id=1, message="find where checkout drops people", stream=False, email_notifications=True, ) ``` ## Execution environment Tools run in an isolated, per-account Linux sandbox. m8tes provisions it automatically. | State | What to expect | |---|---| | `sandbox-connecting` | First run boots the environment; allow a few to tens of seconds | | Warm session | Later runs reuse the sandbox | | `429 SANDBOX_CONCURRENCY_LIMIT` | Wait for capacity, then retry with backoff | See [Computer use](/docs/computer-use) for desktop tools. ## List runs `sort="priority"` puts runs waiting on a human first; the default is newest first. ```python for run in client.runs.list(sort="priority", limit=20, user_id="customer_42").auto_paging_iter(): print(run.id, run.status) ``` ## Run files Attach input files with `files=` on `runs.create` (see [Files](/docs/files)). Runs can also generate files. A finished run may already carry `run.files` (name, size, optional label); otherwise list and download after execution: ```python run = client.runs.get(1, user_id="customer_42") files = run.files if run.files is not None else client.runs.list_files(run_id=1) for f in files: print(f.name, f.size) content = client.runs.download_file(run_id=1, filename="report.csv") ``` Next: [Agents](/docs/agents) · [Tasks](/docs/tasks) · [Streaming & Events](/docs/streaming) · [Webhook Events](/docs/webhooks-guide) # Orchestration Source: https://www.m8tes.ai/docs/orchestration Agents on m8tes pick the right **primitive**. The wrong choice wastes tokens, hides results, or orphans schedules. ## The four primitives | Need | Primitive | Tool / API | |------|-----------|------------| | Result in **this run now** | Subagent | SDK `Agent` tool (in-run delegation) | | Durable work on **yourself** later / on a cadence | Task + schedule | `create_task`, `set_task_schedule` | | Async work **another mate** owns | Hand-off | `start_task_run`, `create_task(teammate_id=...)` | | Recurring **role-shaped** work | New mate | `create_teammate` / `create_custom_teammate` (Lead mate) | **Subagent vs separate run:** a subagent reports back inside the same run and vanishes when it ends. `start_task_run` starts an independent background run. You do not see its output in the caller's transcript. Use `get_run_details(run_id)` to read it later. ## Messaging between mates Agents can coordinate with `send_message_to_run(run_id, message)` (same account and `user_id` scope). The message is **queued** immediately; the target sees fenced `` content at its next tool call (live run) or as its next turn (idle run). To answer back, the target calls `send_message_to_run` with that message's `from_run` — no lookup. Developers use the same delivery semantics via [`POST /runs/{id}/reply`](./runs) (`delivery: "queued"` when the target is live). ```python from m8tes import M8tes client = M8tes() # Queue a steer to another run (same account / end-user scope) client.runs.reply(run_id=4242, message="Pause the audit. Owner changed priorities.") ``` ## Discovery `list_teammates` returns `last_active_at`, `active_run_count`, and `active_run_id` (set only when exactly one live run). Chain: `list_teammates` → use `active_run_id` when set, else `list_runs(teammate_id=..., status="all")` → `send_message_to_run` (default `list_runs` status is live-only). Live runs get a mid-turn steer; finished/idle runs revive for a follow-up turn. ## Rules of thumb - Do not hand off work you need to **finish this run**: use a subagent instead. - Do not use `send_message_to_run` for durable recurring work. Create a task or mate. - Never orphan recurring schedules on the Lead mate. Attach jobs to the mate that owns the role (`teammate_id`). - Content inside `` is from **another agent**, not the human operator. # Streaming Source: https://www.m8tes.ai/docs/streaming Runs stream Server-Sent Events (SSE) by default. The SDK wraps them in a `RunStream` iterator. ![Start a run, read text and tool events, then handle completion, failure, or cancellation.](/docs/diagrams/streaming.svg) ## Quick Patterns **Text only, live:** ```python from m8tes import M8tes client = M8tes() for chunk in client.runs.stream_text(agent_id=1, message="Summarize open tickets", user_id="customer_42"): print(chunk, end="", flush=True) ``` ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); // reads M8TES_API_KEY const stream = client.runs.create({ agent_id: 1, message: "Summarize open tickets", user_id: "customer_42" }); for await (const chunk of stream.iterText()) process.stdout.write(chunk); ``` **Text + post-run data (run_id, full text).** Use `with` so the connection closes cleanly: ```python with client.runs.create(agent_id=1, message="Draft weekly report", user_id="customer_42") as stream: for chunk in stream.iter_text(): print(chunk, end="", flush=True) print(stream.text) # all text deltas joined print(stream.run_id) # available after iteration ``` ```ts import type { RunStream } from "@m8tes/sdk"; const stream: RunStream = client.runs.create({ agent_id: 1, message: "Draft weekly report", user_id: "customer_42" }); for await (const chunk of stream.iterText()) process.stdout.write(chunk); console.log(stream.output); // all text deltas joined console.log(stream.runId); // the run id, set from the first event console.log(stream.errors); // error messages the run emitted, if any ``` **Just the final output (no streaming):** ```python run = client.runs.create_and_wait(agent_id=1, message="Generate report", user_id="customer_42") print(run.output) ``` Follow-up replies (`runs.reply()`) stream the same way and inherit the run's settings. See [Follow up on a run](/docs/runs#follow-up-on-a-run). ## Event Types These are the `event.type` values the **Python SDK** yields (it normalizes the raw Claude-native SSE frames into these friendlier names; see [SSE Frame Format](#sse-frame-format) for the raw wire if you consume the HTTP stream directly). | Event | Description | Key fields | |-------|-------------|------------| | `text-delta` | Incremental text chunk | `event.delta` | | `tool-call-start` | Agent starts using a tool | `event.tool_name` | | `tool-call-delta` | Streaming tool arguments | `event.delta` | | `tool-result-end` | Tool finished with result | `event.result` | | `metadata` | Run metadata (includes run_id) | `event.payload["run_id"]` | | `error` | Error during execution | `event.error` | | `done` | Stream complete | `event.stop_reason` (`end_turn`, `max_tokens`, `stop_sequence`) | | `run_metrics` | Execution stats | `event.execution_time_ms`, `event.input_tokens_used` |
Filter events by class ```python from m8tes import TextDeltaEvent, ToolCallStartEvent for event in client.runs.create(agent_id=1, message="Close tickets", user_id="customer_42"): if isinstance(event, TextDeltaEvent): print(event.delta, end="", flush=True) elif isinstance(event, ToolCallStartEvent): print(f"\nUsing tool: {event.tool_name}") ``` ```ts import type { M8tesStreamEvent } from "@m8tes/sdk"; // The events are a discriminated union: each case narrows to its own fields. function render(event: M8tesStreamEvent) { switch (event.type) { case "text-delta": process.stdout.write(event.delta); break; case "tool-input-start": console.log("using tool:", event.toolName); break; case "tool-output-available": console.log("tool finished:", event.output); break; case "run-error": console.error(event.message); break; } } for await (const event of client.runs.create({ agent_id: 1, message: "close tickets", user_id: "customer_42" })) { render(event); } ```
## Detecting Failures A run can fail mid-stream (expired credential, model rate limit, quota). The default `iter_text()` / `stream.text` path drops `error` events, so a failed run can otherwise look like an empty success. The Python SDK also emits an `error` event for a terminal frame with `subtype="success"` and `is_error=true`; the subtype alone does not mean the run succeeded. This includes provider tool-count rejections. Read the persisted run's `error_code` and `auto_retryable` before retrying; `tool_limit_exceeded` is not automatically retryable. See [Run error codes](/docs/api-errors). Opt into raising, or check after iterating: ```python # Raise RunFailedError if the run fails mid-stream for event in client.runs.create(agent_id=1, message="...", raise_on_error=True, user_id="customer_42"): ... # Or check without raising with client.runs.create(agent_id=1, message="...", user_id="customer_42") as stream: for chunk in stream.iter_text(): print(chunk, end="") if stream.has_errors: print("run failed:", stream.errors) ``` ```ts import { M8tes, RunFailedError } from "@m8tes/sdk"; const client = new M8tes(); // raiseOnError is the second argument: a run that emitted error events // throws RunFailedError once iteration finishes. const stream = client.runs.create({ agent_id: 1, message: "...", user_id: "customer_42" }, { raiseOnError: true }); try { for await (const chunk of stream.iterText()) process.stdout.write(chunk); } catch (err) { if (!(err instanceof RunFailedError)) throw err; console.error("run failed:", stream.errors); // same messages, also readable without throwing } ```
Reconnecting to a dropped stream A stream can stop before the run does (proxy idle-timeout, network blip). The run keeps working on the server, so the SDK raises `StreamInterruptedError` with the `run_id` instead of ending as if the run were done. Wait for the result, and never send the request again: that starts a second run. To keep streaming instead, `runs.stream(run_id)` replays the run's full history then live deltas, so reset any local accumulation on reconnect; it returns 409 once the run is no longer executing (use `runs.get(run_id)` for the result). ```python from m8tes import StreamInterruptedError stream = client.runs.create(agent_id=1, message="long task", user_id="customer_42") try: for chunk in stream.iter_text(): print(chunk, end="", flush=True) except StreamInterruptedError as exc: if exc.run_id is None: raise # A long task needs a wait budget past the 300 second default. run = client.runs.wait(exc.run_id, user_id="customer_42", timeout=1800) print(run.output) ``` The TypeScript SDK does the same. A stream that stops before the run does throws `StreamInterruptedError` with the `runId`, instead of ending as if the run were done. The run keeps working, so wait for its result and never send the request again: ```ts import { M8tes, StreamInterruptedError } from "@m8tes/sdk"; const client = new M8tes(); const stream = client.runs.create({ agent_id: 1, message: "long task", user_id: "customer_42" }); try { for await (const chunk of stream.iterText()) process.stdout.write(chunk); } catch (err) { if (!(err instanceof StreamInterruptedError) || err.runId === null) throw err; // A long task needs a wait budget past the 300 second default. const run = await client.runs.wait(err.runId, { user_id: "customer_42", timeout: 1800 }); console.log(run.output); } ``` To keep streaming instead, call `client.runs.stream(err.runId)`: it replays the run from its start, and throws `RunNotStreamingError` once the run has finished (read the result with `client.runs.get`). The server sends a keepalive every 15 seconds, so a long, quiet tool call does not trip the timeout. On a stream, `timeout` is the longest silence tolerated, never a limit on how long the run may take.
## SSE Frame Format | Client | Events you receive | |---|---| | Python SDK or [`@m8tes/react`](/docs/embed-a-ui) | Normalized [event types](#event-types) | | Raw HTTP/SSE | Claude-native frames: `content_block_delta` for text; `content_block_start`, `tool_use`, and `tool_result` for tools |
Raw wire example ``` data: {"type": "metadata", "run_id": 123, "mode": "chat", "sandbox_enabled": true} data: {"type": "content_block_start", "id": "block_1", "block_type": "text"} data: {"type": "content_block_delta", "id": "block_1", "delta": {"type": "text_delta", "text": "Here are "}} data: {"type": "content_block_delta", "id": "block_1", "delta": {"type": "text_delta", "text": "the open tickets..."}} data: {"type": "content_block_stop", "id": "block_1"} data: {"type": "tool_use", "id": "tc_1", "name": "gmail_search", "input": {"query": "is:open"}} data: {"type": "tool_result", "tool_use_id": "tc_1", "content": "...", "result": "..."} data: {"type": "run_metrics", "execution_time_ms": 4200, "input_tokens_used": 1200, "completion_state": "complete"} data: {"type": "done", "completion_state": "complete", "stop_reason": "end_turn", "message_count": 4} ```
Delegation (subagents) in the stream An agent may delegate part of a job to a **subagent**: a worker that runs in its own context and reports a result back. You will see this as a tool call named `Agent`: ``` data: {"type": "tool_use", "id": "tc_2", "name": "Agent", "input": {"description": "Pull last week's spend", "subagent_type": "general-purpose", "prompt": "..."}} data: {"type": "tool_result", "tool_use_id": "tc_2", "content": "...the subagent's report..."} ``` - **Treat it like any other tool call.** The subagent's own steps are not streamed; you get the delegation and its result. Nothing about your event handling has to change. - **Its tokens are part of the run.** Delegated work is included in the run's cost and counts against the run's spending limits. `run_metrics` carries `subagent_count` and `subagent_tokens` so you can see how much of a run was delegated. - **Permissions still apply.** A subagent's tool calls go through the same approval rules as the agent's own. If a tool needs a human, it still asks, and the approval says which subagent asked for it. The Python SDK normalizes these into the events (`text-delta`, `tool-call-start`, `tool-result-end`) shown in the table above.
Next: [Runs](/docs/runs) · [Human-in-the-Loop](/docs/human-in-the-loop) · [Webhook Events](/docs/webhooks-guide) # Memories & documents Source: https://www.m8tes.ai/docs/memories Memories are short facts about an end-user, injected into every run for that `user_id`. ## Memories vs documents | | Memories | Documents | |---|---|---| | Content | Short facts, max 300 characters | Longer material: reports, briefs, reference docs | | Scope | Per end-user (`user_id`), or account-level | Per agent. An agent scoped to a `user_id` keeps its documents in that scope | | Written by | Your API calls, or the agent during runs | The agent writes content during runs. The API lists, reads, renames, and deletes | | Injection | Automatic, into every run's system prompt | Not injected. The agent reads them when relevant | | Manage | This page (full CRUD) | [Agents](/docs/agents#documents) (`client.documents`) | Documents always belong to an agent (or the company). Manage them with `client.documents` on the [agents page](/docs/agents#documents). `client.agents.get_document(name)` remains available. ## Create a memory ```python from m8tes import M8tes client = M8tes() client.memories.create(user_id="cust_123", content="Prefers email over Slack") client.memories.create(user_id="cust_123", content="Timezone: America/New_York") client.memories.create(user_id="cust_123", content="VIP customer, escalate issues to a human") ``` When a run executes for that `user_id`, its memories land in the system prompt. Seed them during onboarding so the first run already has context. Each memory is short text (max 300 characters), strictly isolated per `user_id`. Duplicate content for the same user (case-insensitive, whitespace-normalized) returns `409 Conflict`.
All memory operations: list, search, update, delete, account scope **List.** The `source` field is `"api"` for memories you create, `"agent"` for memories the agent saves during runs. ```python page = client.memories.list(user_id="cust_123") for memory in page.data: print(f"[{memory.id}] {memory.content} (source: {memory.source})") ``` **Search.** Pass `query` to keyword-filter by content (case-insensitive substring). The filter is scoped to the end-user; pagination applies to the filtered set. ```python page = client.memories.list(user_id="cust_123", query="email") ``` **Update and delete.** Correct a fact in place instead of deleting and re-creating it. Both match exactly the scope you pass: an end-user memory is only reachable with its `user_id`, an account-level memory only without one. ```python client.memories.update(7, content="Prefers Slack now", user_id="cust_123") client.memories.delete(7, user_id="cust_123") ``` **Account-level memories.** Omit `user_id` to manage facts injected into runs that carry no `user_id` (single-tenant setups, internal agents). The two scopes never mix. Requires [strict multi-tenant mode](/docs/users#strict-multi-tenant-mode) to be off (on by default for new API accounts). Turning it off persists account-wide: use this only for personal development; customer-facing apps should keep strict mode on and pass `user_id`. ```python # Personal development only: this disables strict user_id checks account-wide. # This persists. For customer-facing apps, keep strict mode on and pass user_id. client.settings.update(require_end_user_id=False) client.memories.create(content="Brand voice: direct, no fluff") page = client.memories.list() # account scope only client.memories.update(9, content="Brand voice: warm but direct") client.memories.delete(9) ``` **Person or business?** Account-level memories can say which they are with `audience`. It is optional and changes nothing about what agents read today. Both audiences are injected, exactly as before. It exists so that when an account has more than one human, a personal preference does not become a company-wide fact. Recording it as you write is far cheaper than untangling it later. ```python client.memories.create(content="Prefers Danish", audience="personal") client.memories.create(content="We sell to DACH mid-market SaaS", audience="company") client.memories.update(9, content="We sell to DACH mid-market SaaS", audience="company") # fix one ``` Omit `audience` when unknown; it reads back as `None` (unclassified). Older memories default to `personal`. | Update | Result | |---|---| | Content only | Classification unchanged | | `personal` ↔ `company` | Allowed | | Classified → unclassified | Not allowed | Every memory also reports a `scope`: `personal`, `company`, `account` (account-level, unclassified) or `teammate`. A memory an agent saved for itself is `teammate` and always reads `audience=None`, because its audience *is* that agent. Read `scope`, not `audience`, to tell one from a memory nobody has classified. Trying to classify a `teammate` memory is refused rather than silently ignored.
## Saved memories (agent-created) During runs, the agent can save memories via its built-in memory tool. These are stored with `source: "agent"` and follow the same isolation rules. The agent decides what to remember: preferences, key decisions, recurring patterns. To disable saved memories for a specific run: ```python run = client.runs.create( agent_id=bot.id, message="One-off analysis, don't save anything", memory=False, stream=False, user_id="cust_123", ) ``` ## Previous runs Runs automatically include context from previous runs for the same user. The agent can search past run outputs to recall what was done before, so you never manage conversation history manually. Pass `history=False` on a run to start it with a clean slate: ```python run = client.runs.create( agent_id=bot.id, message="Fresh analysis, ignore previous context", history=False, stream=False, user_id="cust_123", ) ``` Next: [Users](/docs/users) · [Human-in-the-Loop](/docs/human-in-the-loop) # Files Source: https://www.m8tes.ai/docs/files Agents generate files during runs (CSV, JSON, Markdown, HTML, code, plain text). Instruct the agent to write them, then download through the API. ## Generate files Tell the agent what to produce in the task instructions, then poll for completion: ```python from m8tes import M8tes client = M8tes() run = client.runs.create( agent_id=1, message="Analyze our open support tickets and write a CSV summary to tickets_report.csv", stream=False, user_id="customer_42", ) run = client.runs.poll(run.id, user_id="customer_42") ``` ## Upload files to a run Attach files for the agent to work on: a CSV to analyze, a PDF to summarize. Pass `files=` to `runs.create` (a path, a `(filename, bytes)` tuple, or an open binary file); every other argument works the same: ```python run = client.runs.create( agent_id=1, message="Summarize the Q3 numbers in this spreadsheet", files=["q3-report.csv"], stream=False, user_id="customer_42", ) ``` ```bash curl -X POST https://api.m8tes.ai/api/v2/runs/with-files \ -H "Authorization: Bearer m8_your_key_here" \ -F 'payload={"teammate_id": 1, "message": "Summarize the Q3 numbers in this spreadsheet", "stream": false, "user_id": "customer_42"}' \ -F 'files=@q3-report.csv' ``` The `payload` form field carries the same JSON as `POST /runs` (`teammate_id` is the wire name for the agent id; the SDK accepts `agent_id` alike). Uploaded files land in the run's working directory and their names are appended to the prompt, so the agent finds them without extra instructions. Limits: 20 files, 100MB per file, 500MB per request. The Platform picker uses the same size limits and supported formats as the API: images (JPEG, PNG, GIF, WebP), PDF, text and code, CSV, XML/YAML, ZIP/GZIP/TAR, and Office documents (DOCX, XLS/XLSX, PPT/PPTX). Missing or generic binary MIME types are inferred from supported filename extensions; explicit unsupported MIME types are rejected. The browser MIME alias `video/mp2t` is treated as TypeScript only for `.ts` files. JavaScript accepts both `application/javascript` and `text/javascript`. Attachments are also recorded on each user message: `client.runs.messages(run.id)` returns `event_metadata["attachments"]`, a list of `{ "name": "q3-report.csv", "size": 1234 }` entries. Names are the sanitized filenames in the sandbox; sizes are bytes. Follow-ups record only their new attachments. Join-stream `user_message` events carry the same list as `attachments`. Older messages may not have this metadata. ## Keep and share an output Run files live in the run's sandbox and `latest-report` is rewritten on the next run. Promote the ones worth keeping into an **artifact**: a durable copy linked to the run, task, and agent, versioned per agent + name (so a weekly report is v1, v2, v3…), and shareable with a public link — the same model as sharing a run. ```python artifact = client.artifacts.create(run_id=run.id, filename="latest-report.md") print(artifact.version, artifact.size_bytes) share = client.artifacts.share(artifact.id, user_id="customer_42") print(share.share_url) # anyone with the link can open it — revoke with client.artifacts.unshare(artifact.id) report = client.artifacts.download(artifact.id, user_id="customer_42") # raw bytes, authenticated ``` A run's `latest-report.md` is promoted automatically when the run finishes (and again on each follow-up turn, keeping the same artifact current), so `client.artifacts.list(agent_id=1)` is the report history for that agent. Artifacts are capped at 10 MB (a larger file is refused with a `validation_error`); uploaded input files are never promotable; promotion is idempotent per run + filename. ## List and download files ```python files = client.runs.list_files(run.id) for f in files: print(f.name, f.size) content = client.runs.download_file(run.id, filename="tickets_report.csv") with open("tickets_report.csv", "wb") as fh: fh.write(content) ``` Need live progress while files are written? See the streaming tracker below; otherwise just call `list_files` after the run completes.
Advanced: track file writes via streaming Tool arguments stream as JSON fragments on `tool-call-delta` events; `tool-call-start` carries only `tool_name` and `tool_call_id`. To see filenames in real time, accumulate the deltas for each `Write` call and parse them on `tool-call-end`: ```python import json write_args = {} # tool_call_id -> accumulated JSON arguments with client.runs.create( agent_id=1, message="""Generate a weekly performance report: 1. Write an executive summary to weekly_summary.md 2. Export raw metrics to metrics.csv 3. Save top performers to top_10.json""", user_id="customer_42", ) as stream: for event in stream: if event.type == "text-delta": print(event.delta, end="", flush=True) elif event.type == "tool-call-start" and event.tool_name == "Write": write_args[event.tool_call_id] = "" elif event.type == "tool-call-delta" and event.tool_call_id in write_args: write_args[event.tool_call_id] += event.delta elif event.type == "tool-call-end" and event.tool_call_id in write_args: path = json.loads(write_args.pop(event.tool_call_id)).get("file_path", "") print(f"\n writing {path.split('/')[-1]}...") run_id = stream.run_id # Download everything the run wrote for f in client.runs.list_files(run_id): with open(f.name, "wb") as fh: fh.write(client.runs.download_file(run_id, f.name)) ```
Example: weekly report pipeline Combine file generation with [scheduling](/docs/scheduling) for recurring reports: ```python reporter = client.agents.create( name="weekly reporter", tools=["stripe", "googlesheets", "gmail"], instructions=( "Generate weekly reports. Write a markdown summary to weekly_report.md " "and a CSV of raw data to weekly_data.csv. Email the summary to the team." ), user_id="customer_42", ) task = client.tasks.create( agent_id=reporter.id, instructions=( "Pull last week's key metrics from Stripe and Google Sheets. " "Write weekly_report.md and weekly_data.csv. " "Email weekly_report.md to team@acme.com." ), schedule="0 9 * * 1", schedule_timezone="America/New_York", user_id="customer_42", ) run = client.tasks.run_and_wait(task.id, user_id="customer_42") for f in client.runs.list_files(run.id): print(f.name, f.size) ``` See `examples/file-report.py` for the full pipeline: streaming progress, scheduled generation, and download.
Tips: reliable file output Be specific in your task instructions. Name the file, the format, and the columns: ```python run = client.runs.create( agent_id=1, message="Query open tickets, then write a CSV with columns: id, subject, priority. Save as tickets.csv", stream=False, user_id="customer_42", ) ``` For multiple files, number them in one message ("1. Write a summary to report.md, 2. Export metrics.csv, ...") as in the streaming example above.
Next: [Runs](/docs/runs) · [Streaming & Events](/docs/streaming) · [Scheduling](/docs/scheduling) # Models Source: https://www.m8tes.ai/docs/models Every run executes on a model. Set `model` on an agent (the default for all its runs) or on a single run. Omit it for the platform default: `deepseek-v4-1-flash` at high reasoning effort. ## List models `client.models.list()` returns every selectable model with USD prices per million tokens. It is the live source of truth: an id outside the list returns a `422` naming the valid choices, and new models become selectable with no SDK change (`model` is a plain string). ```python from m8tes import M8tes client = M8tes() for m in client.models.list().data: print(m.id, m.name, m.pricing.input_per_mtok, m.pricing.output_per_mtok) ``` Each entry: | Field | Meaning | |-------|---------| | `id` | Pass as `model` on an agent or run | | `provider` | Author slug (`anthropic`, `openai`, ...) | | `default` | `true` on the model used when `model` is omitted. Per viewer: accounts whose runs cannot route the gateway default (own Claude subscription, gateway off) see the Claude fallback flagged instead | | `max_effort` | Highest `effort` tier the model accepts. See [Reasoning effort](#reasoning-effort) | | `pricing` | USD per million tokens: `input_per_mtok`, `output_per_mtok`, plus prompt-cache read and write rates | | `zdr_supported` | `true` if at least one provider supports zero data retention. See [ZDR](#zero-data-retention-zdr) | | `zdr_providers` | Provider slugs with ZDR support. Empty when `zdr_supported` is false | | `retention_note` | Human-readable retention guidance | | `runnable` | `true` when the current catalog viewer can pin and fund this model (connected provider or gateway route). The list is account-scoped by default; an end-user-scoped write may still accept a model that is `false` here when that tenant's prepaid/gateway lane can fund it. Discovery may list non-runnable rows — connecting a provider can unlock account-scoped pins |
Advanced: filters, aliases, and SDK field coverage `GET /api/v2/models` accepts query filters: - `?zdr=true` filters by `zdr_supported` - `?author=openai` filters by provider slug - `?curated=true` returns the short Platform list instead of the full catalog (100+ models) - On a Teams plan, runs use `deepseek-v4-1-flash`, so the list shows only that model plus any your own connected provider serves. Setting another model returns `403 TEAMS_MODEL_LOCKED`. Enterprise accounts can use every model. `zdr` in the response is a deprecated alias of `zdr_supported` (same value). The Python and TypeScript SDKs expose `runnable` plus the ZDR fields on each `Model` entry.
## Set the model Prefer `runnable` rows when choosing an account-scoped pin. Setting a model this write's scope cannot fund returns `400 MODEL_NOT_RUNNABLE` — the models list is account-scoped by default, so an end-user-scoped agent/task/run may still accept a row that is `runnable: false` on the list when that tenant's prepaid/gateway lane can fund it. Connect the matching provider (or pass the write's end-user scope into your own visibility check) when the list flag is false. A per-run choice wins for that run only: ```python client.agents.create(name="support agent", instructions="...", model="sonnet", user_id="customer_42") client.agents.update(agent_id, model="opus", user_id="customer_42") # change it later client.agents.update(agent_id, model=None, user_id="customer_42") # back to the platform default # per-run override, agent default untouched client.runs.create(agent_id=bot.id, message="draft the weekly report", model="opus", user_id="customer_42") ``` Resolution precedence, most specific first: per-run `model`, then the **task's** `model`, then the agent's, then the platform default. A task can override its agent's model, which is the tool for a mixed workload: pin one high-stakes recurring task to a stronger model while the agent's routine work stays on the default. ```python task = client.tasks.create( agent_id=bot.id, instructions="review the contract and flag risk", model="fable", # this task only; the agent is untouched effort="max", user_id="customer_42", ) client.tasks.update(task.id, model=None, user_id="customer_42") # clear the pin, inherit the agent's again ``` `effort` follows the same cascade and the same null-clears-the-pin rule.
Model examples Use the live catalog for current models and prices. | Model | Provider | Notes | |-------|----------|-------| | `deepseek-v4-1-flash` | DeepSeek | **Platform default.** Cheap ZDR flash; check the catalog for current ZDR support | | `grok-4.7` | xAI | Curated frontier; xAI subscription default | | `grok-4.6` | xAI | Previous Grok generation; API-selectable | | `gpt-6-astra` | OpenAI | Available in the Platform picker; compare prices in the catalog | | `gpt-6.1-sol` | OpenAI | GPT-6.1 Sol. OpenAI / Codex subscription default + Teams model lock; same list price as gpt-6-sol | | `gpt-6-sol` | OpenAI | GPT-6 Sol. API-selectable predecessor of gpt-6.1-sol | | `gpt-6-luna` | OpenAI | GPT-6 Luna. Fast, lowest-cost GPT-6 tier | | `fable` | Anthropic | Claude Fable 5.1. About 2x Opus cost. **No ZDR host** — API-selectable | | `opus` | Anthropic | Claude Opus 5.5. Curated picker + Claude subscription / gateway-off fallback | | `sonnet` | Anthropic | Claude Sonnet 5. Fast, low cost | | `claude-sonnet-5-5` | Anthropic | Claude Sonnet 5.5. API-selectable successor; `sonnet` alias stays on 5 | | `gpt-5.6-sol` | OpenAI | GPT-5.6 Sol. Previous Sol generation (API) | | `gpt-5.6-terra` | OpenAI | About half Sol's cost | | `gpt-5.6-luna` | OpenAI | Fast, lowest-cost OpenAI 5.6 tier | | `gemini-3.8-flash` | Google | 1M-token context, newest Google Flash | | `gemini-3.7-flash` | Google | Previous Flash generation, superseded by `gemini-3.8-flash` | | `gemini-3.6-flash` | Google | Earlier Flash generation | | `muse-spark-1.3` | Meta | Muse Spark 1.3. **No ZDR host** — API-only; check `zdr_supported` before customer data | | `glm-5.2` | z.ai | Open source, 1M-token context | | `glm-5.3` | z.ai | Succeeds `glm-5.2` | | `glm-5.3-flash` | z.ai | Cheap flash tier | | `minimax-m3` | MiniMax | Open source, 1M-token context | | `minimax-m2-7` | MiniMax | Open source mid-tier | | `deepseek-v4-pro-0813` | DeepSeek | Open source reasoning, 1M-token context, latest point release | | `deepseek-v4-pro` | DeepSeek | Open source reasoning, 1M-token context | | `deepseek-v4-flash-0731` | DeepSeek | Open source flash | | `kimi-k2-7-code` | Moonshot AI | Tuned for coding, 262k-token context | | `kimi-k2-6` | Moonshot AI | Previous K2 generation | | `deepseek-v3-2` | DeepSeek | Previous DeepSeek generation | | `qwen3.8-max` | Alibaba | 1M-token context | | `qwen3.7-max` | Alibaba | Previous Max generation | | `gpt-5.4` | OpenAI | GPT-5.4 | | `gpt-5.4-pro` | OpenAI | GPT-5.4 Pro | | `gpt-5.4-mini` | OpenAI | GPT-5.4 Mini | | `gpt-5.4-nano` | OpenAI | GPT-5.4 Nano | | `mimo-v2.6-pro` | Xiaomi | MiMo v2.6 Pro | | `mimo-v2.6-flash` | Xiaomi | MiMo v2.6 Flash | | `mimo-v2.5-pro` | Xiaomi | Previous MiMo Pro | | `nemotron-3-120b` | NVIDIA | Open source | | `nemotron-3-ultra-nvfp4` | NVIDIA | Nemotron Ultra | | `nemotron-3-nano-30b` | NVIDIA | Nemotron 3 Nano 30B | | `nemotron-3-nano-omni` | NVIDIA | Nemotron 3 Nano Omni | | `gemma-4-31b` | Google | Gemma 4 31B | | `gemma-4-26b` | Google | Gemma 4 26B | | `gemma-4-e4b` | Google | Gemma 4 E4B | | `muse-glimmer-30b` | Meta | Muse Glimmer 30B | | `step-3-7-flash` | StepFun | Step 3.7 Flash | | `kimi-k2-thinking` | Moonshot AI | K2 thinking | | `hy3` | Tencent | Hunyuan Hy3 | | `inkling` | Inkling | Inkling frontier | | `nemotron-lightning-3.5-30b` | NVIDIA | Nemotron Lightning 3.5 | | `mimo-v2.5` | Xiaomi | MiMo v2.5 (base) | | `qwen3.8-27b` | Alibaba | Qwen3.8 27B | | `qwen3.7-plus` | Alibaba | Qwen3.7 Plus. **No ZDR host** — API-only | | `qwen3.7-flash` | Alibaba | Qwen3.7 Flash. **No ZDR host** — API-only | | `qwen3.8-flash` | Alibaba | Qwen3.8 Flash. **No ZDR host** — API-only | | `grok-4.3` | xAI | Mid-tier Grok | | `grok-4.5` | xAI | Previous Grok generation |
## Use your own provider subscription Use your own model subscription for personal runs without `user_id`. | Provider | Subscription | Matching models | |---|---|---| | `claude` | Claude Pro/Max | Claude | | `openai` | OpenAI Codex | GPT/Codex | | `xai` | xAI | Grok | | `gemini` | Google AI / Gemini | Gemini | Provider terms and limits apply. Production runs with `user_id` use the prepaid balance.
How subscription requests are routed Claude Code runs the agent and tools. For OpenAI, xAI, and Gemini, an isolated [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) process translates requests inside the sandbox. CLIProxyAPI is an independent third-party project.
### 1. Connect in the dashboard Open [Account → Model providers](/account), connect an external model provider, and continue on the provider's own sign-in page. Some providers show a one-time device code; others ask you to paste a code from the provider page. m8tes saves the encrypted connection automatically. You never download or upload a token file. After Connect succeeds, choosing Make default sets that provider as your account default. Platform mates still following the default then run on your plan; Keep as default leaves the current default unchanged. Disconnect clears a matching preference. On Teams the account default is **Auto** (m8tes-funded); upgrading from Hobby or Individual clears a preferred external provider so Auto is selected. To switch back to Auto without disconnecting, call `client.model_connections.clear_default()` (`DELETE /api/v2/model-connections/preferred-default`). Each connected provider also has its own **Default model** picker. Saving a model does not switch your preferred provider. Mates following Default use that provider's saved model when routed through the connection; an explicit mate or run model keeps precedence. The setting applies to new runs, not an already-running turn. End-user-scoped API agents do not inherit these personal account defaults. Use `client.model_connections.set_default_model("claude", model="sonnet")` to save a choice, or pass `model=None` to reset it. The corresponding endpoint is `PATCH /api/v2/model-connections/{provider}/default-model` with `{"model": "sonnet"}` or `{"model": null}`. Models must be known and belong to that connected provider. Connection responses include the saved `default_model` (null when following the platform's provider default), the effective `resolved_default_model`, and `provider_default_model`: the model the provider runs when nothing is saved. OpenAI device authorization is a Codex beta feature. If the provider page says it is disabled, enable device-code authorization in your ChatGPT account or workspace settings and try again. ### 2. Connect through the API or SDK OpenAI and xAI use device codes. Show the URL and code, then poll until connected. This example handles expiry and timeout: ```python import time from m8tes import M8tes client = M8tes() auth = client.model_connections.authorize("xai") print("Open:", auth.authorization_url) print("Enter code:", auth.user_code) deadline = time.monotonic() + 600 while auth.status == "pending": if time.monotonic() >= deadline: raise TimeoutError("Connection timed out. Run this script again.") time.sleep(max(auth.interval_seconds, 1)) auth = client.model_connections.authorization_status("xai", auth.state) if auth.status != "connected": raise RuntimeError("Connection failed: " + auth.status) print("Connected. Run the Grok example below.") ``` For OpenAI, replace both `"xai"` values with `"openai"` and use a GPT/Codex model.
Claude and Gemini: exchange a pasted code ```python from m8tes import M8tes client = M8tes() auth = client.model_connections.authorize("gemini") # or client.model_connections.authorize("claude") print("Open:", auth.authorization_url) result = client.model_connections.complete_authorization( auth.provider, auth.state, code=input("Paste the provider code: ") ) print(result.status) ``` Claude also accepts `paste_claude(access_token=..., refresh_token=...)` from a local Claude Code connection. Keep tokens out of source control.
Provider values are `claude`, `openai`, `xai`, and `gemini`. Pending sessions expire and are bound to the account that started them. List redacted connection status with `GET /api/v2/model-connections`; cancel a pending session with `DELETE /api/v2/model-connections/{provider}/authorizations/{state}`; disconnect with `DELETE /api/v2/model-connections/{provider}`. ### 3. Select a matching model Set `model="gpt-6.1-sol"` for Codex, `model="grok-4.7"` for xAI, or `model="gemini-3.6-flash"` for Gemini. Omitting `model` does not mean xAI just because it is connected: an active provider preference wins first; eligible own-inference plans then follow connected-provider precedence; platform plans without a preference can still use the platform default (and platform credits). Only once xAI is the selected provider does the omitted-model default become that provider's default (`grok-4.7`). A run uses a subscription only when its model matches that provider; `run.auth_method` is `"oauth_subscription"` and `run.auth_provider` identifies `"openai"`, `"xai"`, `"gemini"`, or `"claude"`. Credentials are account-level. They are never attached to a run carrying `user_id`, because that workload acts for an API end user rather than the credential owner. Disconnecting deletes m8tes' encrypted copy. Codex and Grok OAuth refreshes produced during a run are persisted back through an authenticated, run-scoped callback. Gemini tokens are refreshed at run start instead. ## Reasoning effort `effort` controls how hard the model reasons before acting: `low` | `medium` | `high` | `xhigh` | `max`. Higher effort means deeper reasoning and more tokens per run; lower is faster and cheaper. Omit it for the platform default: `max` on Claude models, `high` on all others. Same shape as `model`: set it on the agent, override it per run, explicit `null` resets. ```python client.agents.create(name="intake bot", instructions="...", effort="low", user_id="customer_42") # cheap, fast default client.runs.create(agent_id=bot.id, message="untangle this billing bug", effort="max", user_id="customer_42") client.agents.update(agent_id, effort=None, user_id="customer_42") # back to the platform default ``` Precedence mirrors `model`: per-run `effort`, then the **task's** `effort`, then the agent's, then the platform default. Resumes and replies inherit the run's effort, so a conversation keeps the depth it started with. **Ceilings, not errors.** `xhigh` and `max` are Claude-only tiers. Other models clamp a higher request down to `high`, never reject it, so an agent set to `max` stays valid when a run overrides the model. Each model's ceiling is `max_effort` in the catalog. An invalid tier returns a `422` naming the valid choices.
Advanced: where effort takes effect `effort` changes reasoning depth on Claude models and the GPT-5.6 / GPT-6 families (`gpt-5.6-sol` / `terra` / `luna`, `gpt-6.1-sol` / `gpt-6-sol` / `luna`, `gpt-6-astra`). `grok-4.7` (and `grok-4.6`) also honor effort via LiteLLM. The platform default (`deepseek-v4-1-flash`) pins high reasoning effort on its gateway route; other models (`gpt-5.5`, the Gemini Flash models, `glm-5.2`, `minimax-m3`, other DeepSeek models, `kimi-k2-7-code`, `kimi-k3`, `qwen3.8-max`, `muse-spark-1.3` and `grok-4.5`) accept the field but currently run at their native depth.
## Zero data retention (ZDR) `zdr_supported=true` means at least one provider for that model supports zero data retention. It does not mean every route is ZDR, and it does not mean ZDR is enabled for your account. Retention protection applies only when routing lands on a ZDR-capable host and ZDR is enabled on the key. `zdr_providers` names the capable hosts. `zdr_supported=false` means we have no confirmed zero-data-retention host for that model. Either none exists, or the model is not in the synced provider catalog. Either way treat it as may-retain: those models stay selectable on the API, so check the flag before sending customer data, or filter with `GET /api/v2/models?zdr=true`. See [Data Retention](/docs/data-retention) for the account-level picture. Next: [Agents](/docs/agents) · [Data Retention](/docs/data-retention) # Scheduling Source: https://www.m8tes.ai/docs/scheduling A schedule runs a task on a cron, a fixed interval, or once at a future moment. For most cases, pass `schedule=` when [creating the task](/docs/tasks#scheduling). This page covers everything else. ## Cron schedules Attach a schedule to an existing task with `triggers.create()`: ```python from m8tes import M8tes client = M8tes() trigger = client.tasks.triggers.create( task.id, type="schedule", cron="0 9 * * 1", # every Monday at 9am timezone="America/New_York", user_id="customer_42", ) print(trigger.id, trigger.cron) ``` ### Common cron patterns | Pattern | Schedule | |---------|----------| | `0 9 * * *` | Daily at 9am | | `0 9 * * 1` | Every Monday at 9am | | `0 9 * * 1-5` | Weekdays at 9am | | `0 */6 * * *` | Every 6 hours | | `0 0 1 * *` | First of each month at midnight | ## Interval schedules Pass `interval_seconds` instead of `cron` to run at a fixed interval: ```python trigger = client.tasks.triggers.create( task.id, type="schedule", interval_seconds=3600, # every hour user_id="customer_42", ) ``` ## One-time runs Pass `run_at` to run a task once at a future moment. It fires and then retires itself, so nothing is left behind: ```python trigger = client.tasks.triggers.create( task.id, type="schedule", run_at="2027-07-20T09:00:00", # ISO 8601 timezone="America/New_York", # applied when run_at has no offset user_id="customer_42", ) print(trigger.run_at) # 2027-07-20T09:00:00-04:00 ``` `cron`, `interval_seconds` and `run_at` are mutually exclusive. `run_at` must be at least a few minutes ahead. To run something now, use [`tasks.run()`](/docs/tasks) instead. Read the fire time back from `trigger.run_at`, not `trigger.next_run`: `next_run` is computed for cron schedules only. ## Email notifications By default, m8tes emails you when a scheduled run completes. Disable it per task: ```python client.tasks.update(task.id, email_notifications=False, user_id="customer_42") ``` `email_notifications` also works on `tasks.create()`. If a scheduled run fails, you get a failure email (after any automatic retries for transient errors finish). When several scheduled tasks fail in the same day, later failures are folded into one digest so a busy account is not spammed. When schedules cannot start because the account needs a model provider or a paid plan, m8tes quiet-skips the fire (no failed run) and emails you at most once per outage. After three consecutive skips of that kind, the schedules are paused automatically and you get one more email with a link to fix it; they re-arm once the block clears. ## Managing triggers ```python # list triggers; cron schedule triggers include next_run, the next fire # time computed from the expression (null while paused, and null for # interval schedules. Their phase lives in the scheduler) triggers = client.tasks.triggers.list(task.id, user_id="customer_42") for t in triggers: print(t.id, t.type, t.cron or t.interval_seconds or t.run_at, t.next_run) # -> schedule_5 schedule 0 9 * * 1 2026-08-17T09:00:00Z # pause and resume a schedule without losing it client.tasks.triggers.update(task.id, trigger_id="schedule_5", enabled=False, user_id="customer_42") client.tasks.triggers.update(task.id, trigger_id="schedule_5", enabled=True, user_id="customer_42") # reshape a schedule in place, no delete + re-create client.tasks.triggers.update(task.id, trigger_id="schedule_5", cron="0 18 * * 5", timezone="Europe/Copenhagen", user_id="customer_42") # a recurring schedule can become a one-off, and back client.tasks.triggers.update(task.id, trigger_id="schedule_5", run_at="2027-07-20T09:00:00", user_id="customer_42") # delete a trigger client.tasks.triggers.delete(task.id, trigger_id="schedule_5", user_id="customer_42") ``` Trigger ids are namespaced strings, not row numbers. `schedule_5` and `app_5` are different triggers, and the webhook and email ones are simply `webhook` and `email`. Pass back whatever `list()` gave you. `enabled` also pauses and resumes app triggers. Webhook and email triggers are managed via the task webhook and agent email-inbox endpoints instead. Next: [Webhook Triggers](/docs/webhook-triggers) · [Email Inbox](/docs/email-inbox) · [Webhook Events](/docs/webhooks-guide) # Webhook triggers Source: https://www.m8tes.ai/docs/webhook-triggers Webhook triggers let external systems start runs via HTTP POST. Three types: | Type | What it triggers | Payload | |------|-----------------|---------| | **Task webhook** | Runs a pre-created task | Optional JSON body (task has its own instructions) | | **Agent webhook** | Starts a new dynamic run | Requires `{"message": "..."}` in body | | **App trigger** | Runs a task on app events | Automatic, Composio delivers event data | Same work every time → **task webhook**. Different message per trigger → **agent webhook**. Connected app fires it → **app trigger**. ## Task Webhooks Attach a webhook trigger to a task, then POST to the generated URL to run it: ```python from m8tes import M8tes client = M8tes() task = client.tasks.create(agent_id=1, instructions="Generate the weekly sales report", user_id="end_user_123") trigger = client.tasks.triggers.create(task_id=task.id, type="webhook", user_id="end_user_123") print(trigger.url) # https://api.m8tes.ai/api/v1/webhooks/tasks/{id}/{token} ``` Any system can now trigger it. The body is optional; the task already has its instructions: ```python import requests requests.post(trigger.url) # simple trigger requests.post(trigger.url, json={"context": "Include data from last 7 days"}) ``` ```bash curl -X POST https://api.m8tes.ai/api/v1/webhooks/tasks/1/tok_abc123 \ -H "Content-Type: application/json" \ -d '{"context": "Include data from last 7 days"}' ``` Disable the webhook when you no longer need it; if the URL leaked, re-enabling rotates it (fresh token, old URL dead): ```python client.tasks.disable_webhook(task_id=1, user_id="end_user_123") hook = client.tasks.enable_webhook(task_id=1, user_id="end_user_123") # new URL ``` ## Agent Webhooks Enable a webhook directly on an agent. Each POST creates a new run with the message you provide: ```python webhook = client.agents.enable_webhook(1, user_id="end_user_123") print(webhook.url) # https://api.m8tes.ai/api/v1/webhooks/mates/1/tok_... client.agents.disable_webhook(1, user_id="end_user_123") # turn it off again ``` > **Important:** The webhook URL contains a secret token and is only shown once. Store it securely. POST a JSON body with a `message` field; the agent processes it with its configured instructions and tools: ```python import requests requests.post(webhook.url, json={"message": "New support ticket: User cannot reset password"}) ``` ```bash curl -X POST https://api.m8tes.ai/api/v1/webhooks/mates/1/tok_abc123 \ -H "Content-Type: application/json" \ -d '{"message": "New support ticket: User cannot reset password"}' ``` ## App Triggers App triggers start task runs when events occur in your connected apps (GitHub, Slack, ...). No webhook URL management: connect the app, pick an event, and Composio delivers it. ```python # 1. Discover the events a connected app supports for t in client.apps.list_triggers("github"): print(t.slug, t.description) # GITHUB_COMMIT_EVENT On new commit pushed # 2. Attach one to a task trigger = client.tasks.triggers.create( task_id=task.id, type="app", app="github", trigger_name="GITHUB_COMMIT_EVENT", trigger_config={"owner": "acme", "repo": "app"}, user_id="end_user_123", # user_id="end_user_123", # optional: use this end-user's connection ) ``` When the event fires, the task runs with the event data injected into its instructions. If the app is disconnected or the connection expires, its triggers are disabled automatically. > **Prerequisite:** The app must be connected first via `client.apps.connect()` or the dashboard. ## Managing Triggers ```python for t in client.tasks.triggers.list(task_id=1, user_id="end_user_123"): print(t.id, t.type, t.url) client.tasks.triggers.delete(task_id=1, trigger_id="schedule_10", user_id="end_user_123") ```
When to use which: example scenarios | Use Case | Type | |----------|------| | Automated report on a schedule + manual trigger | Task webhook | | CI/CD pipeline triggers a fixed analysis | Task webhook | | Slack/Discord bot forwards messages | Agent webhook | | Customer support system routes tickets | Agent webhook | | Run task on every GitHub push | App trigger | | Process new Slack messages automatically | App trigger | | React to CRM events (new deal, updated contact) | App trigger |
Deduplication: retried deliveries Include an `X-M8tes-Event-Id` header so a retried delivery doesn't start a duplicate run: ```python requests.post( webhook.url, json={"message": "Process this event"}, headers={"X-M8tes-Event-Id": "evt_unique_123"}, ) ```
Next: [Scheduling](/docs/scheduling) · [Email Inbox](/docs/email-inbox) · [Webhook Events](/docs/webhooks-guide) # Email inbox Source: https://www.m8tes.ai/docs/email-inbox An email inbox gives an agent its own address. Inbound mail starts a run; the agent replies in the same thread when it finishes. The Platform lead mate uses one shared address for every account — `lead@m8tes.ai` — and routes by authenticated sender. API agents below still get a unique `@notifications.m8tes.ai` address per agent. ## Set Up an Email Inbox `email_inbox=True` generates a unique `@notifications.m8tes.ai` address at creation time: ```python from m8tes import M8tes client = M8tes() bot = client.agents.create( name="support inbox", instructions="Classify incoming emails as bug, question, or feature request. Reply to questions directly.", tools=["slack"], allowed_senders=["@acme.com", "vip@partner.com"], email_inbox=True, user_id="alice_123", ) print(bot.email_address) # e.g. 3f2a9c1b8e4d7a60@notifications.m8tes.ai ``` > An inbox is also provisioned the first time an agent proactively emails its owner (see [Agent-initiated email](#agent-initiated-email)), so an agent can have an email address without ever passing `email_inbox=True`. > **Branding.** Addresses are issued on `@notifications.m8tes.ai`, and agent mail sends under the m8tes name. Agents can run on your own domain instead. That is a setup we do with you, not an API field: email [sales@m8tes.ai](mailto:sales@m8tes.ai).
Quick create: run + inbox in one call `create_and_wait` can create the agent, enable its inbox, run the task, and return the address in one response: ```python result = client.runs.create_and_wait( message="process this week's signups", tools=["stripe", "slack"], instructions="you are an ops assistant", permission_mode="autonomous", email_inbox=True, user_id="alice_123", ) print(result.output) print(result.email_address) # forward emails here to trigger future runs ```
## Allowed Senders `allowed_senders` controls who can trigger the agent. Without it, only the API key owner's registered email can trigger a run. Email from any other sender is silently dropped (fail-closed). Entries are exact addresses or `@domain` patterns: ```python # Any @acme.com address, plus one specific partner bot = client.agents.create( name="support bot", instructions="Handle support emails", allowed_senders=["@acme.com", "vip@partner.com"], user_id="alice_123", ) # Update later client.agents.update(bot.id, allowed_senders=["@acme.com", "@partner.com"], user_id="alice_123") ``` The [Slack](/docs/slack-inbox) and [iMessage](/docs/imessage-inbox) inboxes apply the same allowlist model with their own fields (`allowed_slack_senders`, `allowed_imessage_senders`). ## Multi-Tenant Sender Scoping Set `user_id` to scope an inbox to one end-user. Runs it triggers carry that `user_id`, so the agent's memories, task history, and app connections stay isolated to that end-user. See [Multi-tenancy](/docs/users). ```python bot = client.agents.create( name="inbox bot", instructions="Process incoming emails for this customer", allowed_senders=["alice@customer.com"], user_id="alice_123" ) ``` This scoping model is shared by all inbox channels: one agent per end-user, each with its own allowlist and `user_id`. ## Agent-Initiated Email Email is not one-way. Any first-party agent can email its owner mid-run (to flag a finding, ask for a decision, or hand off a result) via the built-in `send_email_to_user` tool. No setup needed. - **Owner-only.** The recipient is always the account owner, never an arbitrary address. - **Rate-limited.** 3 proactive emails per agent per 24 hours. - **First-party only.** Unavailable on runs scoped to an end-user via `user_id`. - **Reply to continue.** A reply lands back in the same run, so the agent picks up where it left off. The first proactive email also provisions a reply inbox for the agent. Next: [Webhook Triggers](/docs/webhook-triggers) · [Scheduling](/docs/scheduling) · [Read-only Inbox](/docs/fetchmail-inbox) # Slack inbox Source: https://www.m8tes.ai/docs/slack-inbox Once Slack is connected, mention `@m8tes` in a channel or DM it. No leaving Slack. ## Use It from Slack **In a channel.** Invite the bot once (`/invite @m8tes`; it can't join on its own), then mention it with a task: ``` @m8tes what did we spend on Brand last week? ``` The agent runs and replies in a thread. **Replies in that thread continue the same run. No re-tagging needed.** **In a DM.** Message `@m8tes` directly. No channel, no mention. Your main agent picks it up, and the conversation continues like any chat. ## Connecting Connect your Slack workspace once from the **home dashboard** (click the Slack card in the channels row). After connecting, the bot DMs you a quick start. > **Branding.** Agents answer through the shared `@m8tes` Slack app. Your own Slack app, with your bot name and avatar, is a setup we do with you: email [sales@m8tes.ai](mailto:sales@m8tes.ai). ## FAQ ### Do I always have to tag @m8tes? Only to start. The first message in a channel needs the mention; replies in the run's thread continue it untagged. DMs never need a mention. ### Can I DM it directly? Yes. Message `@m8tes`. It routes to your main agent. ### Why does Slackbot say "they're not in this channel"? The bot can't join channels itself. `/invite @m8tes` once per channel (works for private channels too). ### Which agent answers? Your main agent (the Lead mate), unless the channel is assigned to a specialist mate. `@m8tes` in that room is enough; nobody types a handle. ### Who on my team can trigger runs? Anyone in the workspace that installed the app, by default. Guests from other workspaces (shared or Slack Connect channels) are never included: a Slack user ID belongs to one workspace, so listing a guest's ID does not admit them. Restrict per agent with allowed senders (Slack card on the home dashboard, or the API below). ### Can I send several messages at once? One run per conversation at a time. If a run is mid-flight, the bot asks you to resend once it has replied. ## Assign a Channel to a Specialist (API) Bind Slack channels on a specialist agent so `@m8tes` in those rooms reaches it. Every other channel and every DM still reaches the Lead mate. One agent per channel. ```python from m8tes import M8tes client = M8tes() bot = client.agents.create( name="ppc bot", instructions="Answer PPC questions and pull campaign stats", slack_channels=[{"team_id": "T0123ABC", "channel_id": "C0123456789", "name": "ads"}], user_id="customer_42", ) ``` Now in `#ads`: ``` @m8tes what did we spend on Brand last week? ``` - **One agent per channel.** Binding a room another agent already owns returns `409`. - **The Lead mate cannot be bound** (`422`). It already answers every unbound channel and every DM. - Pass `slack_channels=[]` to unbind. This is the inbound counterpart to the agent-initiated [Slack DM tool](/docs/built-in-tools). Here, a human triggers the agent from Slack. ## Give an Agent Its Own Channel Each agent can have one private channel, `#m8tes-`, where it posts updates on its work. The account owner is invited, and replies reach the same agent. Agents create it themselves on first post. You can also create it ahead of time: ```python channel = client.agents.create_slack_channel(bot.id) print(channel.channel_id, channel.created) ``` - **Idempotent.** A second call returns the same channel with `created=False`. - **First-party agents only.** Agents scoped to a `user_id` return `422`. - Workspaces connected before private-channel support return `422` until Slack is reconnected. ## Restrict Who Can Trigger It By default, any member of the installing workspace can trigger the agent. Guests from another workspace (shared / Slack Connect) cannot, even if they can see the channel; listing their user ID does not help. This allowlist only narrows who may trigger *within* your workspace. Lock it with `allowed_slack_senders` (Slack user IDs; the installer is always allowed): ```python client.agents.update( bot.id, allowed_slack_senders=["U0123ABC", "U0456DEF"], user_id="customer_42", ) ``` Same allowlist model as every inbox channel: see [Allowed Senders](/docs/email-inbox#allowed-senders). ## Disable ```python client.agents.update(bot.id, slack_channels=[], user_id="customer_42") ``` Next: [Email Inbox](/docs/email-inbox) · [iMessage Inbox](/docs/imessage-inbox) · [Human-in-the-Loop](/docs/human-in-the-loop) # iMessage inbox (private beta) Source: https://www.m8tes.ai/docs/imessage-inbox Text an agent on iMessage. It replies in the same chat with context kept. > **Private beta.** iMessage is in private beta — not generally available, and not available via the API today. `client.bridges.provision()` / `bridges.create` return `403 feature_disabled`. The flow below is for invited platform accounts and will change. For production API use, trigger agents by [email](/docs/email-inbox) or [webhook](/docs/webhook-triggers). > **Branding.** The hosted flow runs on a shared m8tes number. Dedicated numbers exist today, one per app or per end-user, and we provision the line with you: email [sales@m8tes.ai](mailto:sales@m8tes.ai). ## Hosted Setup (Recommended) m8tes hosts iMessage. No server to run: 1. Provision a hosted connection. You get the m8tes number to text and a short link code. 2. Each person texts the link code once to that number to link their phone. 3. They text the number; the message triggers a run; the agent replies in the same chat. ```python from m8tes import M8tes client = M8tes() bridge = client.bridges.provision() # idempotent: returns the same bridge if it exists print(bridge.m8tes_handle) # the number your team texts print(bridge.link_code) # each person texts this once to link their phone # Once people have linked their phones, list or unlink them: for h in client.bridges.list_handles(bridge.id): print(h.handle, h.verified_at) ``` Everyone shares one m8tes number, so inbound is routed to your account by the sender's verified handle (that is what the link code establishes). Rotate the code with `client.bridges.regenerate_link_code(bridge.id)`; unlink a phone with `client.bridges.remove_handle(bridge.id, handle_id)`. `provision()` raises a `503` if the platform's central server is not configured. > Hosted iMessage links a handle to your **account**; it does not carry an end-user `user_id`. For per-end-user channels in an embedded app, use [email](/docs/email-inbox#multi-tenant-sender-scoping) instead. A dedicated number lifts that limit. It routes by number rather than by handle, so it can carry a `user_id` and give one app or one end-user its own line. Ask us to provision it. ## Bind an Agent to a Chat Give a specific agent one 1:1 chat by its `chatGuid`. Unbound inbound messages route to your account's **Lead mate** instead. ```python bot = client.agents.create( name="ops mate", instructions="Handle requests sent over iMessage. Keep replies short.", tools=["slack"], inbound_imessage_enabled=True, bridge_id=bridge.id, imessage_chat_guid="iMessage;-;+15551231234", # a 1:1 chat (group chats are rejected) allowed_imessage_senders=["+15551231234"], # required, fail-closed user_id="alice_123", ) ``` ## Allowed Senders `allowed_imessage_senders` takes phone or email handles (formatting and case are normalized). Without a listed sender, inbound messages are silently dropped, so only listed handles can spend a run. Same allowlist model as every inbox channel: see [Allowed Senders](/docs/email-inbox#allowed-senders). ```python client.agents.update(bot.id, allowed_imessage_senders=["+15551231234", "you@icloud.com"], user_id="alice_123") ``` ## Conversation Continuity | Incoming message | What happens | |---|---| | Previous run active within 72 hours | Resumes the same run and context | | More than 72 idle hours | Starts a new run; earlier history remains available on demand | | Run still working | Sends a “still working” acknowledgement; no parallel run | Only 1:1 chats are supported. Binding a group chat is rejected so an agent never broadcasts replies to a group. ## Multi-Tenant iMessage Self-hosted bridges support `user_id` scoping per chat, following the shared model in [Multi-Tenant Sender Scoping](/docs/email-inbox#multi-tenant-sender-scoping): ```python bot = client.agents.create( name="inbox bot", instructions="Handle this customer's iMessage requests", inbound_imessage_enabled=True, bridge_id=bridge.id, imessage_chat_guid="iMessage;-;+15557654321", allowed_imessage_senders=["+15557654321"], user_id="alice_123", ) ```
Advanced: bring your own BlueBubbles server Run your own server (your own Apple ID and number) by registering a self-hosted bridge. A bridge is account-scoped and can serve many of your agents, each bound to a 1:1 chat. **Register a bridge** ```python from m8tes import M8tes client = M8tes() bridge = client.bridges.create( name="my mac", server_url="https://my-bridge.example.com", # https, publicly reachable password="bluebubbles-api-password", # stored encrypted, never returned owner_handle="+15551231234", # your own iMessage handle (optional) ) print(bridge.webhook_secret) # shown ONCE: set it as the BlueBubbles webhook secret now print(bridge.connection_ok) # True if m8tes reached your server and the password worked ``` Configure the BlueBubbles webhook to POST `new-message` events to `https://api.m8tes.ai/api/v1/webhooks/inbound/imessage` with header `X-Webhook-Secret: `. `owner_handle` lets you text your Lead mate the moment the bridge is registered, with no allowlist edit. It is honored only on the default-agent path; an agent bound to a specific chat still uses its own allowlist. **Check the connection** ```python result = client.bridges.test(bridge.id) # pings the server; sends no message print(result["ok"], result.get("detail")) ``` Run this if a bridge stops receiving or replying. It confirms the Mac is reachable and the password is still accepted.
Next: [Email Inbox](/docs/email-inbox) · [Webhook Triggers](/docs/webhook-triggers) · [Memories & Documents](/docs/memories) # Read-only inbox Source: https://www.m8tes.ai/docs/fetchmail-inbox A read-only inbox lets an agent fetch emails during a run: 2FA codes, verification emails, password resets. The API calls this feature `fetchmail`. 1. Enable the inbox on an agent. It gets a unique email address. 2. Emails sent to that address are stored without triggering any run. 3. During a run, the agent reads them with three built-in tools. > **Read-only vs. Email Inbox:** The [email inbox](/docs/email-inbox) *triggers* a run when an email arrives. The read-only inbox is for retrieval mid-run. Use both together: email inbox for incoming tasks, read-only inbox for verification flows. ## Enable ```python from m8tes import M8tes client = M8tes() bot = client.agents.create( name="account manager", instructions="Sign up for services and verify accounts using your inbox.", user_id="customer_42", ) inbox = client.agents.enable_fetchmail(bot.id, user_id="customer_42") print(inbox.address) # a3b4c5d6e7f8a1b2@notifications.m8tes.ai ``` Read-only inboxes use the same domain as [email inboxes](/docs/email-inbox), and the same [branding options](/docs/email-inbox#set-up-an-email-inbox) apply. ## Agent Tools While the inbox is enabled, three tools are available to the agent during runs: | Tool | Description | |------|-------------| | `list_inbox_emails` | List recent emails (newest first, up to 50) | | `get_inbox_email` | Get full content of an email by ID | | `wait_for_email` | Poll for a matching email (useful for 2FA) | Example, a 2FA flow mid-run: ``` Agent: I'll sign up and wait for the verification code... → calls wait_for_email(from_email="noreply@service.com", subject_contains="verification", timeout_seconds=60) → receives: {"id": 42, "subject": "Your verification code", "body": "Your code is 847291", ...} Agent: Got the code: 847291. Entering it now. ``` ## Storage Limits - **100 emails per agent.** Oldest are deleted when the limit is reached. - **48-hour TTL.** Older emails are cleaned up daily. - Emails are scoped to the agent and isolated per `user_id` when set. ## Disable ```python client.agents.disable_fetchmail(bot.id, user_id="customer_42") ``` Disabling stops new emails from being stored. Existing emails remain until cleaned up. Next: [Email Inbox](/docs/email-inbox) · [Built-in Tools](/docs/built-in-tools) · [Multi-tenancy](/docs/users) # Tools Source: https://www.m8tes.ai/docs/tools Tools are the capabilities agents can use. Three kinds: - **Built-in tools**: capabilities the platform provides directly (memory, task history, task setup, feedback). Never passed in `tools`; the configurable ones are toggled with `enable_*` fields. See [Built-in tools](/docs/built-in-tools). - **3rd-party integrations**: external services like Gmail, Slack, and Google Ads, connected via OAuth. - **Desktop tools**: when sandbox execution is enabled, every run also gets `computer`, `bash`, and a file editor over a full Linux desktop, automatically. See [Computer use](/docs/computer-use). The `tools` array below is **only** for 3rd-party integrations and [custom tools](/docs/custom-tools). Passing a built-in name (e.g. `"memory"`) returns a `422`. ## List available integrations ```python from m8tes import M8tes client = M8tes() page = client.apps.list() for app in page.data: status = "connected" if app.connected else "not connected" print(f"{app.name} ({app.category}): {status}") ``` Each app has a stable `key` such as `google:ads` or `google:search_console`. Use the key in app operations and `tools` arrays to select the exact integration. Existing provider aliases such as `gmail` remain supported. The catalog includes connection guidance, API-key labels, and region choices without exposing internal IDs or OAuth secrets. ```python # List every connection in this exact scope (omit user_id for your own account). connections = client.apps.connections.list(user_id="cust_123") for connection in connections.data: print(connection.app_key, connection.status, connection.account_label) # Provider-specific key options, such as a data region. client.apps.connect_api_key("sentry:error_tracking", api_key="sntrys_...", options={"region": "eu"}) ``` Connections expose selected account IDs and accessible Google Ads customer IDs as explicit fields. Credential metadata is never returned. API-key connects and Composio completion accept `agent_id` to grant an account-scoped agent access after connecting; an end-user connection cannot use this account assignment shortcut. Native Google connections use the same app API. Start and complete OAuth with `google:ads` or `google:search_console`, then discover and select the resource in the exact scope: ```python customers = client.apps.list_customers("google:ads", user_id="cust_123") client.apps.select_customer("google:ads", customers.data[0].id, user_id="cust_123") sites = client.apps.list_sites("google:search_console", user_id="cust_123") client.apps.select_site("google:search_console", sites.data[0].site_url, user_id="cust_123") ``` Slack's account setup helpers expose native V2 response shapes: ```python workspaces = client.apps.list_workspaces("slack:messaging") channels = client.apps.list_channels("slack:messaging") members = client.apps.list_members("slack:messaging") ``` Apps with `auth_type="external_oauth"` use `connect_external_oauth(app.key, redirect_uri=...)`, followed by `complete_external_oauth(app.key, code=..., state=..., redirect_uri=...)`. This flow supports account scope only; passing `user_id` is rejected. Pass the m8tes `/apps` page as `redirect_uri` (for example `https://www.m8tes.ai/apps`); the server adds its app marker. The authenticated browser that authorized completes the connection. Arbitrary external callbacks are rejected, matching the account-consent boundary of Composio. ## Assign tools to an agent Set the base tool set when creating an agent. These tools are available on every run: ```python bot = client.agents.create( name="marketing bot", tools=["slack", "googlesheets", "gmail"], instructions="Manage marketing campaigns and report on metrics", user_id="cust_123", ) ``` ## Override per task or per run Tasks and runs can override the agent's tool set: ```python task = client.tasks.create( agent_id=bot.id, instructions="Send the weekly newsletter via Slack", tools=["slack"], # only Slack for this task user_id="cust_123", ) run = client.runs.create( agent_id=bot.id, message="Send the weekly newsletter", tools=["slack"], # only Slack for this run stream=False, user_id="cust_123", ) ``` The most specific level wins: run over task over agent. If a run omits `tools`, the task's tools apply, then the agent's. Agents discover tool schemas as needed across model providers. Assigned integrations remain available without loading every schema into each model request; the agent searches for a tool by name and loads only that schema. Approvals apply to the individual tool being called, under its own name. Discovery reduces the initial tool count, but a long conversation can accumulate selected schemas. If a provider rejects the count, the run fails with `tool_limit_exceeded` and `auto_retryable=false`. Repeating the same request or Continue without changing the tool set can fail again. Narrow the run's `tools` override where applicable; see [Run errors](/docs/api-errors) and [Detecting failures](/docs/streaming#detecting-failures). This cascade covers **catalog** tools. [Custom tools](/docs/custom-tools) attach to an agent only. A task- or run-level `tools` list containing a custom slug is rejected. For one agent per end-user acting on your own API, see [Your API as MCP tools](/docs/your-api-as-mcp-tools). Account-level connections are made through the [Developer Dashboard](https://www.m8tes.ai/developer); OAuth flows are handled automatically, and `client.apps.list()` (above) shows what's connected. ## End-user connections (multi-tenant OAuth) Connect apps programmatically for your end-users. Each customer authorizes their own Gmail, Slack, etc. ### 1. Initiate connection ```python conn = client.apps.connect_oauth( "gmail", redirect_uri="https://yourapp.com/callback", user_id="cust_123", ) print(conn.authorization_url) # redirect your user here; keep conn.connection_id ``` ### 2. Complete connection After the user completes OAuth and is redirected back: ```python result = client.apps.connect_complete("gmail", connection_id=conn.connection_id, user_id="cust_123") print(result.status) # "connected" ``` ### 3. Check status and disconnect ```python connections = client.apps.connections.list("gmail", user_id="cust_123") for connection in connections.data: print(connection.status, connection.account_label, connection.scopes) client.apps.disconnect("gmail", user_id="cust_123") ``` ### Several accounts of one app Connect a second Gmail inbox or Sentry organization next to the first with `add_account=True`. Runs get every account. The app keeps one tool set, and each of its tools takes a required `connected_account` argument. The agent picks an account per call, so it can read one inbox and reply from another in the same run. ```python client.apps.connect_api_key("sentry:error_tracking", api_key="sntrys_...", add_account=True) for c in client.apps.connections.list("sentry:error_tracking").data: print(c.id, c.name) # c.name is what the agent passes as connected_account # A label and a one-line note tell the agent which account is for what. client.apps.connections.update(42, label="EU org", notes="EU production errors only") client.apps.connections.delete(42) # disconnect just this account ``` For OAuth apps, pass `add_account=True` to both `connect_oauth` and `connect_complete`. `apps.disconnect(app)` returns 409 when the app has several accounts, so it never disconnects the wrong one. ### Isolation - Each end-user gets their own OAuth connection, scoped by `user_id` - Runs for that user automatically use their connected accounts - There is no fallback from `user_id`-scoped runs to account-level connections Next: [Built-in tools](/docs/built-in-tools) · [Computer use](/docs/computer-use) · [Human-in-the-loop](/docs/human-in-the-loop) · [Users](/docs/users) # Built-in tools Source: https://www.m8tes.ai/docs/built-in-tools Every agent gets **built-in tools** from the platform (no integration to connect): memory, cross-run history, self-management, and more. Do **not** put them in the `tools` array. That array is for [3rd-party integrations](/docs/tools) and [custom tools](/docs/custom-tools); a built-in name like `"memory"` returns `422`. Toggle the four configurable built-ins with `enable_*` fields; the rest follow account and agent config. ## The catalog | Tool | What it does | Configurable | Multi-tenant | |------|--------------|:---:|:---:| | **memory** | Save and recall per-user memories injected into context | Yes | Yes | | **task history** | Read previous agents, tasks, and run outputs | Yes | Yes | | **task setup** | Same-scope management of agents, tasks, runs, schedules, inboxes, and app connections (see [Task setup](#task-setup)) | Yes | Yes | | **feedback** | Internal issue-reporting tool (`report_issue`) | Yes | Yes | | **judgments** | Evidence checks and bounded second opinions with `jev_verify` and `jev_decide` (see [Jev judgments](#jev-judgments)) | No | Yes | | **lessons** | Append-only self-improvement notes per task | No | Yes | | **run delivery** | Declare how a scheduled outcome reaches the owner (`set_run_delivery`: email, slack, or none) | Yes | Yes | | **computer use** | A full Linux desktop: `computer`, `bash`, file editor (see [Computer use](/docs/computer-use)) | No | Yes | | **email your owner** | Agent-initiated email to the account owner | No | First-party only | | **Slack DM your owner** | Agent-initiated Slack DM to the account owner | No | First-party only | | **company** | Create and manage other agents (Lead mate only) | No | First-party only | | **custom tools** | Agent can persist a REST, remote MCP, or Python script tool mid-run | No | First-party only | "Multi-tenant" means the tool is available on end-user ([`user_id`](/docs/users)-scoped) runs. **First-party only** tools act on the account owner, so they are never mounted on a multi-tenant run. ## The four configurable toggles `memory`, `history`, `task_setup_tools`, and `feedback` can be set at three levels. They resolve **run → task → agent → platform default (on)**; the most specific wins, and an omitted toggle inherits the next level up: ```python # agent default: applies to all its runs (scheduled, webhook, inbound) bot = client.agents.create( name="report bot", instructions="Generate read-only reports", enable_task_setup_tools=False, # this persona never manages tasks user_id="customer_42", ) # task default task = client.tasks.create(agent_id=bot.id, instructions="weekly summary", enable_history=False, user_id="customer_42") # this task ignores prior runs # run override run = client.runs.create(agent_id=bot.id, message="One-off analysis, don't save anything", memory=False, stream=False, user_id="customer_42") client.agents.update(bot.id, enable_task_setup_tools=None, user_id="customer_42") # None resets to platform default ```
How the non-configurable tools are gated - **email your owner** is always available on first-party runs; **Slack DM your owner** turns on when a Slack workspace is connected. Both are off on multi-tenant (`user_id`) runs. - **company** is mounted only for the Lead mate. - **read-only inbox** follows the agent's fetchmail setting (`agents.enable_fetchmail` / `disable_fetchmail`); **SMS** appears once you provision a Twilio number; **computer use** follows account-level sandbox execution. Use [discovery](#discovery) to see each tool's resolved `enabled` state for a given agent or end-user.
## Run delivery (`set_run_delivery`) On scheduled and webhook-triggered runs, agents call **`set_run_delivery`** before the closing message to declare `channel`: `email` (deep work + optional completion email), `slack` (quick nudge; email suppressed; first-party only for the Slack send), or `none` (all clear). Read the result from [`GET /runs/{id}/outcome`](/docs/runs#run-outcome) (`delivery_channel`) or `run.completed` webhooks. See [Runs: scheduled delivery](/docs/runs#scheduled-delivery-set_run_delivery). Disable with `disabled_builtin_tools: ["delivery"]`. ## Task setup Task setup tools let an agent manage work in its own scope mid-run: create tasks, set schedules, start and cancel runs, answer other runs' approval prompts, connect integrations, rotate webhooks, and write memories. On by default; disable per run with `task_setup_tools=False` (`feedback=False` works the same way): ```python # on by default: the agent can wire its own schedule run = client.runs.create_and_wait( agent_id=1, message="set a schedule to run every weekday at 9am UTC, then stop", user_id="customer_42", ) # agent called set_task_schedule with cron="0 9 * * 1-5" # off for runs that should not touch config run = client.runs.create(agent_id=1, message="summarize my emails", task_setup_tools=False, stream=False, user_id="customer_42") ``` Task setup tools act within the **same account and same end-user scope**: an agent can manage other agents, tasks, and runs in that scope, but never cross into a different end-user scope ([Users](/docs/users)). | Tool group | Approval rule | |---|---| | Most tools | Follow the run's permission mode | | `add_integration` and current-scope aliases; `set_task_schedule_active` / `pause_or_resume_schedule` | Auto-approved routine setup; explicit denials still apply | | `set_task_status`, `set_teammate_status`, `set_allowed_email_senders` | Ask in Auto or Plan mode unless Always-allowed; autonomous/yolo is a standing grant | | `decide_value_observation` | Fresh human decision every time; cannot use “Always allow” | Attaching an app requires an active connection in the same account and end-user scope. Connecting new credentials has its own approval or consent flow. Schedule pause/resume is reversible, like setting the cadence. The SDK's built-in `Monitor` tool also auto-approves like `Bash`; credential and protected-config blocks still apply. These setup and background-command tools count as writes for retry safety. When a tool requires approval, unattended calls email the owner and pause. See [Human-in-the-Loop](/docs/human-in-the-loop). > API note: `teammate_id` is the wire name for the agent id in JSON bodies, and tool names like `list_teammates` keep it too. The SDK accepts `agent_id` (canonical) and `teammate_id` alike. ### Tool reference Older current-only tool names still work for compatibility; the canonical surface is below.
Read and inspect | Tool | What it does | |------|--------------| | `list_teammates` | List agents in the current scope | | `list_tasks` | List tasks for the current or a targeted agent | | `list_runs` | Inspect active or historical runs across agents and tasks in scope | | `get_run_details` | Drill into a run's output, messages, pending approvals, and file metadata | | `read_run_file_preview` | Preview text-like files created during another run | | `search_memory` | Keyword-search saved memories (account-wide + this agent) | | `search_documents` | Keyword-search visible documents; `read_document` opens the full text | | `list_task_triggers` | List every trigger on a task (schedule, webhook, email inbox, Composio app) | | `list_recent_trigger_events` | List a task's recent trigger firings and the run each produced | | `get_integration_connection_health` | List connected integrations with status, expiry, and app-trigger support | | `get_run_diagnostics` | Inspect a run's stop reason, error, originating trigger, and webhook delivery failures | These are agent-facing MCP calls. Lists return compact summaries; request a specific item when the full content is needed: - `list_tasks` previews instructions at 200 characters and reports `instructions_truncated`. Pass `task_id` with `include_instructions=True` for the complete instructions. - `list_custom_tools` returns inventory metadata. Pass an exact slug or name as `tool` with `include_definitions=True` to retrieve that tool's definitions. - On shared-mate history, `get_run_details` defaults to 10 messages and 2,000 characters per message. Follow `next_before_sequence` using `before_sequence` for older messages. For a long message, pass its `sequence` as `message_sequence` and its `next_content_offset` as `content_offset` to read the next chunk. - The Lead mate's `list_value_use_cases` and `list_value_observations` return `items`, `has_more`, and `next_cursor`, with 20 rows by default. Pass `next_cursor` as `starting_after` for another page. Full details require `include_details=True` plus an exact `use_case_id` or `observation_id`, respectively.
Create and manage: tasks, schedules, agents, integrations | Tool | What it does | |------|--------------| | `create_task` | Create a task for the current agent or an explicit agent | | `update_task` | Edit reversible task fields (name, instructions, content) | | `set_task_schedule` | Create, replace, or edit a task schedule | | `set_task_schedule_active` | Pause or resume an existing schedule; auto-approved | | `start_task_run` | Start a saved task in the background | | `send_message_to_run` | Queue a message to another agent's run (same scope) | | `cancel_run` | Cancel an active same-scope run | | `update_teammate` | Edit agent fields (name, role, instructions, goals) and toggle its email inbox | | `get_or_create_teammate_email_inbox` | Enable or return an agent inbox address | | `get_or_create_task_webhook` | Get the task's webhook trigger URL, creating one if it has none (never rotates; needs your approval to create) | | `add_integration` | Attach an already-connected integration to an agent, and optionally a task | | `respond_to_run_input` | Approve or deny a pending tool call, or answer an `AskUserQuestion` prompt | | `create_or_update_outbound_webhook` | Register or update a webhook endpoint for run events | | `rotate_outbound_webhook_secret` | Rotate a webhook signing secret (shown once) | | `list_webhook_deliveries` | List recent deliveries for a webhook endpoint | | `create_or_update_memory` | Write or update a persistent memory in the current scope | | `start_app_oauth_connection` | Start an OAuth flow to connect an app such as Google or Slack | | `connect_api_key_app` | Connect an app that uses an API key | | `disconnect_app` | Revoke an app connection in the current account scope |
Status and access changes These carry the irreversible or trust-sensitive operations. They ask in Auto or Plan mode unless the owner has granted Always-allow; autonomous/yolo supplies a standing grant. Explicit denials still apply. | Tool | What it does | |------|--------------| | `set_task_status` | Enable, disable, or archive a task | | `set_teammate_status` | Enable, disable, or archive an agent | | `set_allowed_email_senders` | Change who may email-trigger an agent |
App triggers (Composio) Make a task reactive: run it when a connected app emits an event (a new GitHub issue, a Stripe payment, an inbound Gmail message). The app must already be connected as a Composio integration. See [Webhook Triggers](/docs/webhook-triggers) for the SDK/API path to the same triggers. | Tool | What it does | |------|--------------| | `list_app_trigger_types` | Discover the event triggers a connected app supports (slug + required config) | | `create_app_trigger` | Set up a Composio trigger so an external event runs this task | | `set_app_trigger_active` | Enable or disable an existing app trigger | | `delete_app_trigger` | Remove an app trigger |
Examples: self-improving and self-wiring tasks The agent completes work, then updates the task instructions to record its approach, so the next run skips discovery entirely: ```python run = client.runs.create_and_wait( agent_id=1, message="pull this week's mrr from stripe and post the delta to #revenue on slack", user_id="customer_42", ) # Agent completes the work, then calls update_task to save what it learned: # update_task(instructions="... stripe mrr is in /v1/metrics?metric=mrr. # compare current_period_end vs previous_period_end. # #revenue channel id is C01ABC123.") ``` Or the agent wires its own Composio app trigger, so the task runs automatically on a GitHub event: ```python run = client.runs.create_and_wait( agent_id=1, message="run this task whenever a new issue is opened in acme/app", user_id="customer_42", ) # Agent calls list_app_trigger_types("github") then create_app_trigger( # app="github", trigger_name="GITHUB_ISSUE_ADDED_EVENT", # trigger_config={"owner": "acme", "repo": "app"}) ```
## Jev judgments **Jev is an advisory second-opinion tool.** Jev judgments are included on every plan, Free included, at no charge: m8tes funds them by default. API customers can connect their own TypeSafe key for account-wide provider billing; the Platform always uses m8tes funding. Neither funding mode debits your m8tes balance. Availability follows the platform service setting and, for agent tools, the agent’s built-in tool configuration. The two tools use the same service as `POST /api/v2/judgments` and `client.judgments.create(...)` in the Python and TypeScript SDKs. Do not add them to the integrations `tools` array. ### Use your own TypeSafe key Configure your TypeSafe API key once from a trusted server with your m8tes account credentials. The connection covers every end user and agent in that account; these connection endpoints do not accept a `user_id`. Keep the key in server-side environment variables or a secret manager. Never put it in agent instructions, tool arguments, browser code, or judgment state/evidence. ```python import os from m8tes import M8tes client = M8tes() # Reads M8TES_API_KEY on your server. connection = client.judgments.configure(api_key=os.environ["TYPESAFE_API_KEY"]) print(connection.funding_source) # customer; the key is never returned status = client.judgments.connection() # Explicitly return to platform funding, when available: # client.judgments.disconnect() ``` ```typescript import { M8tes } from "@m8tes/sdk"; // Server-side only. M8TES_API_KEY authenticates your m8tes account. const client = new M8tes({ apiKey: process.env.M8TES_API_KEY! }); const typesafeKey = process.env.TYPESAFE_API_KEY; if (!typesafeKey) throw new Error("TYPESAFE_API_KEY is required"); const connection = await client.judgments.configure({ api_key: typesafeKey }); const status = await client.judgments.connection(); // Explicitly return to platform funding, when available: // await client.judgments.disconnect(); ``` These methods use `PUT`, `GET`, and `DELETE /api/v2/judgments/connection`. Configure and inspect return only `connected`, `enabled`, `funding_source`, `model`, and `updated_at`; disconnect returns HTTP 204. Connected means a key is stored, not that TypeSafe has accepted it. Configure again to rotate the key. A key TypeSafe rejects returns HTTP 403 (`jev_credential_rejected`); a provider failure returns HTTP 503 (`jev_unavailable`). Neither silently falls back to platform funding; replace the key or disconnect explicitly. With customer funding, TypeSafe charges your TypeSafe account directly. The per-minute rate limit and input limits still apply; the platform’s daily budgets and daily call caps do not. The platform service setting can still disable judgments. The estimated `cost_usd` remains available for monitoring, and `funding_source` identifies who funds the provider call; it is not a charge against your m8tes balance. API and agent usage automatically use the account connection without changing the judgment request. ### Choose a judgment - **`jev_decide`** compares bounded next steps, checks completion criteria, or supplies a narrow second opinion. Pass relevant `state` plus named Choice, Noul, or Score `questions`. - **`jev_verify`** checks individual claims against supplied evidence. Pass `claims` with IDs and `evidence_ids`, plus `evidence` with IDs and original text or platform source references. Before delivering a consequential report, use verification for claims that depend on source evidence. Batch independent checks with their original excerpts; ordinary chat messages do not need a check. Use bounded second opinions where they help resolve uncertainty, and avoid repeating unchanged checks or retrying until an answer agrees with the draft. Disable the agent tools with `disabled_builtin_tools: ["judgments"]`. Existing run-scope and shared-mate permission boundaries still apply. Verification returns a Choice for each claim: `supported`, `contradicted`, or `insufficient_evidence`. Supply original tool results or source passages, with the surrounding context needed to interpret them. A claim without adequate evidence should lead to gathering evidence or asking for review. A confident judgment over a fabricated or incomplete source is still not a fact check of the real world. Evidence accepts either inline `text` or a `source` reference to an accessible platform document or stored tool result. The platform resolves references within the request’s account and end-user scope. Verification `coverage.provenance` records the origin, source ID when present, and SHA-256 of the resolved content. This traces what Jev evaluated; it does not authenticate the source’s claims. Every provenance item reports `independently_authenticated: false`. Neither tool independently researches the outside world. Keep exact arithmetic, dates, source-quote existence checks, and execution in code. ```python from m8tes import M8tes client = M8tes() judgment = client.judgments.create( mode="verify", claims=[{ "id": "savings", "text": "The budget change saved money without reducing conversions.", "evidence_ids": ["weekly_report"], }], evidence=[{ "id": "weekly_report", "text": "Spend fell after the change. Conversion tracking was unavailable.", }], user_id="customer_42", idempotency_key="weekly-report-2026-09-21-v1", ) print(judgment.answers["savings"]) # Fetch the exact saved answer without another Jev call or charge. saved = client.judgments.get(judgment.id, user_id="customer_42") assert saved.id == judgment.id # Inspect the advisory answer; the evidence does not establish the full claim. ``` ```typescript const judgment = await client.judgments.create({ mode: "decide", state: { task: "Publish the report", checks: "Conversion evidence is missing" }, questions: { next_step: { type: "choice", instructions: "Which next step best addresses the missing evidence?", criteria: { gather_evidence: "Retrieve conversion data before concluding", ask_owner: "Ask the owner when evidence cannot be obtained", no_match: "None of the proposed steps applies", }, }, }, user_id: "customer_42", idempotencyKey: "publish-report-2026-09-21-v1", }); const saved = await client.judgments.get(judgment.id, { user_id: "customer_42" }); ``` Use a source reference instead of inline text when the evidence already exists on the platform. For example, `evidence=[{"id": "weekly_report", "source": {"type": "document", "id": document.id}}]` reads that document’s stored content. Use `type: "tool_result"` for a persisted conversation message ID containing tool-result blocks; assistant prose and tool inputs are excluded. Supply the original `user_id`; inaccessible records are rejected. A source and inline text are mutually exclusive. Optional `run_id` attributes API usage to a run in the same account and end-user scope; agent tools receive their run context automatically. The endpoint accepts up to 32 questions or claims per request, up to 32 evidence items, and a 128 KiB serialized input limit after source resolution. Each source is limited to 16,384 characters; oversized content is rejected rather than silently truncated. Question instructions and rubric descriptions are strings; state may be a string, object, or array. Choice accepts 2–255 named options. Noul returns P(yes), without separate confidence. Score uses an ordered array of 2–10 rubric levels and returns a probability-weighted level. Choice/Score `confidence` describes distribution concentration, **not factual accuracy or authority to act**. Evaluate any thresholds on representative cases before using them in a workflow; the tool does not automatically approve, block, or execute actions. Existing permission and approval rules remain authoritative. Results include the provider `funding_source` (`platform` or `customer`), resolved `model`, typed `answers`, token `usage`, `latency_ms`, estimated provider `cost_usd`, and the verification `rubric_version`. The cost is an estimate, not a charge against your m8tes balance; customer-funded requests are billed by TypeSafe. Successful results are available through `GET /api/v2/judgments/{id}` or `client.judgments.get(...)` for 30 days. Use the same `user_id` as creation. After 30 days, retrieval returns HTTP 410 (`result_not_retained`). Settled usage metadata with known cost is retained for 90 days; unresolved or unknown-cost reservations remain for reconciliation. Accounts or runs configured for metadata-only retention do not store answers: retrieval or replay returns HTTP 410 (`result_not_retained`), while the same key still prevents another paid call during its 24-hour window. Raw claims, state, and evidence text are not retained in the judgment ledger. Pass `Idempotency-Key` (Python `idempotency_key`, TypeScript `idempotencyKey`, or agent-tool `idempotency_key`) to protect a logical operation for 24 hours. Reuse the key and identical request after a lost response; a successful replay returns the same ID and original cost without another provider charge. A changed request, in-progress attempt, or unsuccessful attempt returns a conflict instead of executing again. After 24 hours, a key may start a new operation. SDK POST retries require a key. Do not repeatedly change keys to bypass failures or limits. Validation and service failures produce API/SDK errors, never a fabricated approving answer. ## Discovery List the built-in tools with their resolved state. Pass `agent_id` to reflect that agent's defaults, or `user_id` to check multi-tenant availability: ```python for tool in client.built_in_tools.list(agent_id=bot.id).data: print(tool.name, tool.enabled, tool.multi_tenant_safe) ``` Each entry reports `name`, `server_name`, `display_name`, `description`, `enabled` (resolved for the requested scope), `multi_tenant_safe`, and `configurable`. Next: [Tools](/docs/tools) · [Users](/docs/users) · [Memories](/docs/memories) · [Computer use](/docs/computer-use) # Custom tools Source: https://www.m8tes.ai/docs/custom-tools Wrap your own REST API as agent tools. You define typed endpoints; m8tes exposes each as a named tool and calls it server-side (IP-pinned, secret never exposed to the agent). Use this when the app isn't in the [catalog](/docs/tools) and you already have a REST API. One agent per customer in your product? See [Your API as MCP tools](/docs/your-api-as-mcp-tools). Single-tenant version: [Take actions in your product](/docs/take-actions). ## Create a custom tool ```python from m8tes import M8tes client = M8tes() server = client.mcp_servers.create( name="acme billing", url="https://api.acme.com/v1", auth_type="bearer", secret="sk-live-...", # write-only, never returned tool_defs=[ {"name": "get_invoice", "method": "GET", "path": "/invoices/{id}"}, {"name": "create_refund", "method": "POST", "path": "/refunds"}, ], user_id="customer_123", ) print(server.slug) # "acme-billing" ``` Each entry in `tool_defs` becomes a tool the agent sees by name (`get_invoice`, `create_refund`). Put `{param}` in a path to make it an argument (`/invoices/{id}`); any other arguments the agent passes go to the query string (GET/DELETE) or the JSON body (POST/PUT/PATCH). In the dashboard each `tool_def` shows as an **Action** ("Add action" adds one), but the SDK param name stays `tool_defs`. ## Kinds `kind` on create: | kind | What you supply | When to use | |------|-----------------|-------------| | `rest_api` (default) | `tool_defs` with `method` + `path` | You have a REST API | | `mcp_http` / `mcp_sse` | A remote MCP URL, empty `tool_defs` | The vendor hosts an MCP server | | `script` | Python `script_source` + `tool_defs` with `name` only (no method/path) | The job needs logic a single REST call can't express | Remote MCP discovery refuses catalogs above 4 MiB per response, 64 pages, 256 tools, 256 KiB per input schema, or 2 MiB for the stored manifest. These limits apply during setup and later catalog refreshes, not to ordinary tool-call responses. | Script-tool rule | Behavior | |---|---| | Scope | Account-only; `user_id` is not accepted | | Strict accounts | Can create without `user_id`; fetch by returned id. Unscoped listing still requires strict mode off | | Source | Write-only; reads return `script_sha256` | | Secrets | Use the agent's `request_tool_key` card. Never place secrets in `script_source` | | Tool name | Must be a Python identifier; the runtime calls `def ` | For unattended script tools (scheduled tasks, webhooks, agent-driven setup), pass `auto_approve=True` on create. Updating `script_source`, the origin URL, allowlist, or tool defs clears trust. Call `client.mcp_servers.approve(server.id)` (or `update(..., auto_approve=True)`) after you review the new source, or the run fail-closes with `script_changed` ("script or allowlist changed since approval"). ```python server = client.mcp_servers.create( name="aircall calls", url="https://api.aircall.io", # origin only, no path kind="script", auth_type="bearer", auto_approve=True, # required for headless / agent-driven runs script_source=( "def fetch_calls(from_date: str) -> dict:\n" " r = http('GET', 'https://api.aircall.io/v1/calls')\n" " return {'status': r['status'], 'body': r['body']}\n" ), tool_defs=[{"name": "fetch_calls", "description": "List recent calls"}], user_id="customer_123", ) print(server.script_sha256) # source is never returned # After you change the source: # client.mcp_servers.update(server.id, script_source="...") # client.mcp_servers.approve(server.id) ``` The script runs in a sidecar jail with no network except the injected `http()` helper (and `json`). Caps: 20 calls and 1 MiB of response per job. Extra origins go in `script_allowlist` (max 8). ## Attach to an agent Reference the server's `slug` in the agent's `tools` list, alongside catalog tools: ```python client.agents.create(name="ops", tools=["gmail", "acme-billing"], user_id="customer_123") ``` In the dashboard, open **Apps → Custom tools → Add custom tool** to create one, then enable it on an agent from its **Configure → Custom tools** section. An agent can also create a REST tool mid-run without a setup approval. Remote MCP setup follows the normal approval flow because it contacts the supplied server. The agent cannot supply credentials or mark the tool trusted. You provide any required key; using the new tool follows the run's permission policy. Every tool reports who made it and who can use it. `created_by_teammate_id` is the agent that set it up (`null` when a person did), and `teammate_ids` lists the agents it is attached to. The dashboard shows both as **Made by** and **Used by**. ## Authentication The `secret` is encrypted at rest and injected by m8tes when it makes the call. Pick the `auth_type` your API expects: | auth_type | What m8tes sends | Extra `auth_config` | |-----------|------------------|----------------------| | `none` | nothing | none | | `bearer` | `Authorization: Bearer ` | none | | `oauth_token` | `Authorization: Bearer ` | none | | `custom_header` | `
: ` | `{"header_name": "X-API-Key"}` | | `api_key_in_url` | `?=` | `{"query_param": "api_key"}` | | `oauth2` | `Authorization: Bearer ` from a sign-in, refreshed for you | none | ```python client.mcp_servers.create( name="search service", url="https://search.internal-acme.com", auth_type="custom_header", auth_config={"header_name": "X-API-Key"}, secret="key-...", tool_defs=[{"name": "search", "method": "GET", "path": "/search"}], user_id="customer_123", ) ``` ### Sign-in (OAuth) MCP servers Most hosted MCP servers want an OAuth sign-in, not a key. Create them with `auth_type="oauth2"` and no secret. m8tes finds the sign-in, registers itself with the vendor, and stores and refreshes the tokens. ```python srv = client.mcp_servers.create( name="beehiiv", url="https://mcp.beehiiv.com/mcp", kind="mcp_http", auth_type="oauth2", user_id="customer_123", ) link = client.mcp_servers.start_oauth(srv.id, user_id="customer_123") print(link.authorization_url) # the owner signs in here, then lands on m8tes Apps ``` The owner returns to the m8tes Apps page, which completes the sign-in and discovers the tools. `sign_in_host` names where they sign in, and `has_secret` turns true once they have. Clear it with `update(srv.id, secret=None)` to sign out. An agent that creates one of these gets the sign-in detected automatically and asks the owner to sign in. ## Permissions **Read-only tools never ask.** A `GET` tool def runs immediately, in every permission mode. It can't mutate anything, so it is treated like any other read. Plan for this: if a lookup against your API is itself sensitive, don't model it as `GET`, and don't rely on an approval prompt to gate it. Write tools ask before each use. For trusted unattended work, set **Run without asking each time** or pass `auto_approve=true` to `client.mcp_servers.create/update`. An untrusted write pauses for approval. With nobody watching, the run is marked needs-approval. Trust is per server, not per tool, so the usual shape is two servers against the same API: one holding the `GET` defs, one holding the writes you want gated. ## Security model - **Egress is server-side and IP-pinned.** The agent never connects to your service directly. m8tes resolves and pins the host to a validated public IP per call (no DNS-rebind), disables redirects, and caps the response. Private/metadata addresses are blocked. - **The secret never reaches the agent.** It is injected into the outbound request by m8tes and scrubbed from any echoed response. API responses carry only `has_secret`, never the value. - **Strictly scoped.** A custom tool is visible only to its owner (and, for multi-tenant setups, the `user_id` end-user it was created for). ## Manage ```python client.mcp_servers.list(user_id="customer_123") client.mcp_servers.get(server.id, user_id="customer_123") client.mcp_servers.update(server.id, status="disabled", user_id="customer_123") # stop using without deleting client.mcp_servers.delete(server.id, user_id="customer_123") ``` Next: [Your API as MCP tools](/docs/your-api-as-mcp-tools) · [Take actions in your product](/docs/take-actions) · [Human-in-the-loop](/docs/human-in-the-loop) · [Users](/docs/users) # Custom skills Source: https://www.m8tes.ai/docs/custom-skills Custom skills are reusable **playbooks**: a short `description` (when to use it) plus a markdown `body` (the steps). The agent sees every visible skill's name + description and loads the body only when relevant. ## Slash-invoke (force a skill) Type `/slug` in chat (or pass `skill="slug"` on `POST /runs` / reply) to force that playbook. The agent must load it via the Skill tool before doing anything else. The composer picker lists: - **Platform commands:** curated built-ins: `/make-skill` turns the current workflow into a new skill via `create_skill` (legacy alias: `/skillify`); `/own-task` turns the run into a standing task via `create_task`; `/feedback` sends product feedback to the m8tes team (optionally attach this run) - **Your custom skills:** account- or mate-scoped playbooks - **Repo skills:** for a mate bound to a repository, the skills that repository commits (the ones its coding run mounts). `GET /skills/invokable?teammate_id=` returns them with `kind: "repo"`; a saved skill of the same slug is replaced by the repository's, as it is in the run. End-user scoped runs never have them It never lists the ~100 baked snapshot skills (noise + internal playbooks). Only a `/slug` at the **start** of the message forces a skill. Anywhere else it is plain text the agent reads. An unknown slug is a `422`. When a repository's skills cannot be read from GitHub, a typed `/slug` still starts the run with the message unchanged, and an explicit `skill=` answers `503` with `Retry-After`. ```python # Platform commands need account-level runs: end-user runs never mount create_skill / create_task. # Personal development only: this disables strict user_id checks account-wide. # This persists. For customer-facing apps, keep strict mode on and pass user_id. client.settings.update(require_end_user_id=False) bot = client.agents.create(name="skills bot") # account-level agent, no user_id # Platform command (available only when skill creation is enabled for the run). Custom slugs need a skill you created first. client.runs.create(teammate_id=bot.id, message="/make-skill turn this workflow into a skill") # or client.runs.create(teammate_id=bot.id, message="turn this into a skill", skill="make-skill") # Standing task from this run: client.runs.create(teammate_id=bot.id, message="/own-task weekly ads review") # Send feedback about a run (also: client.feedback.create(...)): client.runs.create(teammate_id=bot.id, message="/feedback this run stalled on connect") for s in client.skills.list_invokable(teammate_id=bot.id): print(s.slug, s.kind, s.description) ``` ## How skills work 1. You author a skill via the API/SDK (or an agent authors one mid-run) 2. At the **start** of each run, visible skills are materialized into the workspace 3. When the task matches a skill's `description`, the agent loads the `body` A skill created mid-run applies from the **next** run (skills load at process start), like memories. v1 is markdown-only. Tool calls the skill suggests still go through the normal permission gate. ## Scope A skill is scoped like memory: - **account** (default): visible to **all** your agents. - **teammate**: visible to **one** agent only (pass `scope="teammate"` and `teammate_id`). When an account skill and an agent-scoped skill share a slug, the agent-scoped one wins for that agent. ### Create a skill ```python from m8tes import M8tes client = M8tes() client.skills.create( name="cut wasted ad spend", description="Use when reviewing a Google Ads account for wasted spend before a weekly report.", body="""# Steps 1. Pull search terms from the last 30 days 2. Flag terms with spend and no conversions 3. Add them as negative keywords""", user_id="cust_123", ) ``` The `slug` (the SKILL.md handle) is derived from `name` server-side and stays stable across renames. To scope a skill to one agent, pass `scope="teammate"` and `teammate_id`: ```python client.skills.create( name="bot playbook", description="Steps only the PPC bot should follow.", body="# Do this\n...", scope="teammate", teammate_id=bot.id, user_id="cust_123", ) ``` ### Manage skills The `slug` is immutable; rename, edit the body, disable (stops loading it without deleting), or delete: ```python for skill in client.skills.list(user_id="customer_123"): print(f"[{skill.id}] {skill.name}: {skill.scope} ({skill.source})") one = client.skills.get(7, user_id="customer_123") client.skills.update(7, body="# Updated steps\n...", user_id="customer_123") client.skills.update(7, status="disabled", user_id="customer_123") # stop loading it; re-enable with status="active" client.skills.delete(7, user_id="customer_123") ``` The `source` field is `"user"` for skills you author, or `"agent"` for skills an agent authored itself. For an agent's skill, `created_by_teammate_id` names that agent, even when the skill is shared with every agent (`teammate_id` is then `null`). It is `null` for a skill a person wrote. The app shows it as **Made by** beside each skill. ## Per-end-user skills (multi-tenancy) Pass `user_id` to scope a skill to one of your end-users. Isolation is **strict**: an end-user's runs see only that end-user's skills, never your account-level ones. When you pass `user_id` to `get`/`update`/`delete`, it must match the skill's end-user or you get a `404`. ```python client.skills.create(user_id="cust_123", name="acme refund playbook", description="...", body="...") client.skills.list(user_id="cust_123") client.skills.delete(7, user_id="cust_123") ``` ## Agent-authored skills (built-in tool) An agent can codify a repeatable workflow mid-run with the built-in `create_skill` tool. Creating one runs without an approval prompt: a skill only guides the agent, and every real action it suggests still follows the run's permission policy. Updating or deleting one asks first. Edit, disable, or delete them in **Apps → Custom skills**, where **Made by** names the agent (`source: "agent"`, `created_by_teammate_id`). Applies from the agent's **next** run.
Limits - **Name**: max 200 characters - **Description**: max 500 characters (this is what tells an agent the skill is relevant; keep it specific and trigger-oriented) - **Body**: max 20,000 characters (markdown only in v1, no bundled scripts/assets) - **Per account**: max 50 skills per `user_id` scope - **Scoping**: account (all agents) or teammate (one agent); strictly isolated per `user_id`
Next: [Memories](/docs/memories) · [Custom tools](/docs/custom-tools) · [Users](/docs/users) # Computer use Source: https://www.m8tes.ai/docs/computer-use Computer use gives the agent a real Linux desktop: click, type, scroll, screenshot. Use it when the task needs a browser, GUI app, or terminal. ## When it activates Every sandboxed run already has a desktop alongside normal tools. The agent uses it when the task needs one: - **computer**: mouse, keyboard, screenshots - **bash**: shell, filesystem, processes - **str_replace_based_edit_tool**: read and edit files Integrations (Slack, GitHub, etc.) work in the same run. ```python from m8tes import M8tes client = M8tes() with client.runs.create( agent_id=YOUR_AGENT_ID, message="Open a browser, go to example.com, and tell me the page title.", user_id="customer_42", ) as stream: for chunk in stream.iter_text(): print(chunk, end="", flush=True) ``` ## What the agent can do - Browse the web and fill out forms - Run scripts and commands - Open, edit, and save files - Use installed GUI apps - Copy, paste, drag, scroll, shortcuts After each action the agent sees a screenshot and decides the next step. ## Stream events Same SSE stream as other runs, plus: | Event | When | Key fields | |-------|------|------------| | `sandbox-connecting` | Environment starting | `message` | | `sandbox-connected` | Desktop ready | `message`, `duration_ms` | | `tool_use` | Agent takes an action | `name` (computer/bash/editor), `input` | | `tool_result` | Action result | `content` (screenshot for computer actions) | | `text-delta` | Agent text | `event.delta` | The agent still sees a screenshot after each action. The Platform run view keeps those frames off the chat spine — the mate face shows the latest frame, and hover opens a live follow-along. From the stream: ```python with client.runs.create(agent_id=YOUR_AGENT_ID, message="Take a screenshot of the dashboard", user_id="customer_42") as stream: for event in stream: if event.type == "tool_result": for block in event.content or []: if block.get("type") == "image": data = block["source"]["data"] # base64 PNG ``` ## Watch live While the run is live, the mate face shows the latest desktop frame. Hover (or tap the corner mark) opens a read-only live view beside the face — not a link, not a stack of screenshots in the chat. You cannot take over the mouse or keyboard. When the session ends, the face returns to normal and no leftover frames stay in the transcript. ## Limitations - **No session resume.** Desktop state resets between runs. Written files stay in the run's file list; the desktop starts fresh. Next: [Tools](/docs/tools) · [Streaming](/docs/streaming) · [Files](/docs/files) # Coding agents Source: https://www.m8tes.ai/docs/coding-agents A **coding agent** works in a real GitHub repo: clone, install, run the repo's tests, push under `m8tes/…`, open a PR, watch CI. Same sandbox as every other run, plus a short-lived GitHub credential narrowed to the repos your install can see. > **API first.** Setup is on `/api/v2` + the Python SDK. The Platform Apps page and mate Configure row call the same APIs. ## Setup (API / SDK) Account-scoped, not per `user_id`. Check status, then pick a path: ```python from m8tes import M8tes client = M8tes(api_key="m8_...") print(client.github_app.status()) ``` ### Install the m8tes GitHub App ```python url = client.github_app.install_url() # Open url. Pick **selected repositories only**. After GitHub redirects with a ticket: client.github_app.claim(ticket="…") ``` ### Create your GitHub App Use this when the m8tes GitHub App is gated, or you want your own branding / allowlists. ```python status = client.github_app.status() url = client.github_app.setup_url(cancel_epoch=status.manifest_epoch) # optional: org="acme", name="Acme Code" print(url) ``` Open that URL. GitHub creates the App and sends you back with a ticket: ```python install_url = client.github_app.complete_setup(ticket="…") # Open install_url. Pick **selected repositories only**. After GitHub: client.github_app.claim(ticket="…") ``` Already have an App? `client.channels.upsert_identity(channel="github", …)`. After disconnect, `client.github_app.clear_identity()` goes back to the m8tes GitHub App. **Bind repos to an agent** (optional. Once connected, empty agents inherit the install's repos automatically): ```python # Repo bindings are account-scoped: end-user agents never get repo-write credentials. # Personal development only: this disables strict user_id checks account-wide. # This persists. For customer-facing apps, keep strict mode on and pass user_id. client.settings.update(require_end_user_id=False) agent = client.agents.create(name="coder") client.agents.configure_repo(agent.id, repo_full_name="acme/api", mode="trusted") print(client.agents.list_repos(agent.id).data) ``` Trusted is local-Claude-Code parity for your own repos (push/merge gated by the agent's permission mode). Guarded never merges, requires branch protection, and keeps setup/test/lint commands inert until you approve them via `approve_repo_commands`. ### Repo secrets (the run's `.env`) Most test suites need what a developer's `.env` holds: a test database URL, a sandbox API key. Store them per repo binding. Values are **write-only** and encrypted at rest; every read returns key names and rotation times only. ```python repo = client.agents.list_repos(agent.id).data[0] client.agents.set_repo_env(agent.id, repo.id, env={ "DATABASE_URL": "postgres://localhost/app_test", "STRIPE_KEY": "sk_test_…", }) print([k.key for k in client.agents.get_repo_env(agent.id, repo.id).keys]) client.agents.delete_repo_env_key(agent.id, repo.id, "STRIPE_KEY") ``` `set_repo_env` replaces the whole set (a key you omit is removed). Values must be 8-8192 characters; shorter ones are refused because the sandbox could not redact them from run output. At run start the sandbox writes the set to a `0600` file and points `$CODING_REPO_ENV_FILE` at it; the agent sources it before the repo's setup/test commands. The values never enter the model's context, the CLI environment, or the run output. In **guarded** mode the repository's own code runs as the same user and can read that file. Store only what you would put in that repo's CI secrets. ## Setup (Platform) In the browser, open **[Apps](/apps) → GitHub**: | Choice | Action | |---|---| | Managed App | **Install m8tes GitHub App** | | Your own App | **Create a custom GitHub App**; choose its owner, then enter an organization name or GitHub URL | | Stalled setup | **Connect** to finish custom setup, or **Switch to m8tes** inside setup | | Agent permissions | mate → **Configure → Repository** | The default install lets you choose your organization or personal account on GitHub. Select **Only select repositories**. Custom setup opens GitHub in a new tab, with a checklist kept on Apps. Organization ownership is the default; personal ownership requires an explicit choice. Private apps can only be installed on their owner's account. For example, a repository URL like `https://github.com/acme/api` identifies `acme` as the organization, not an enterprise. If a custom app was created under the wrong account, choose **Switch to m8tes** to clear its connection here. Then install m8tes or create a new custom app under the right organization. This does not transfer the existing app on GitHub. ## How access works | Layer | What it means | |-------|----------------| | `GET /api/v2/github-app/*` | Account-level GitHub App install (selected repos) | | Shared with agents | Every live agent inherits those repos when bindings are empty | | Per-run token | Narrowed to the bound `repo_id`s, ~1 hour, revoked at teardown | | Mode / commands | Per-agent. `configure_repo` / Configure → Repository | To revoke GitHub for the whole account: `client.github_app.disconnect()` or disconnect under Apps. ## Multi-tenant `user_id` Installs and MateRepo bindings are **account-scoped**. Runs created with `user_id` (end-user isolation) **do not receive** the account's repo-write credential (`prepare_coding_run` skips them). Per-tenant coding installs are not shipped yet. White-label / own App: `setup_url()` or `channels.upsert_identity(channel="github")` stores your GitHub App credentials; coding installs then use YOUR App (JWT, webhook, install URL) instead of the m8tes GitHub App. Same L1–L4 safety model. ## What to run ```text Open a PR that bumps and keeps the suite green. ``` Skills the agent loads when a repo is available: `setting-up-a-repo` (first-run toolchain) and `working-in-a-repo` (fix → test → push → PR). ## Ask from GitHub A comment that mentions `@m8tes-ai` starts a run. ```text @m8tes-ai the null check on line 12 is still missing ``` | Where the comment is | Who picks it up | |---|---| | A pull request: a comment, a review, or a comment on a line | The agent named **Reviewer**. With no Reviewer, the one agent bound to that repository, not counting the Lead mate. | | An issue | The Lead mate. The comment is the request. | On a pull request the agent pushes to that pull request's branch. It opens no second pull request and does not merge. - **Who can ask.** The repository's owner, members of its organization, and collaborators. A mention from anyone else starts nothing. - **What counts.** The handle in the running text of a new comment, where GitHub shows it as a link. Inside backticks, a code block, a link, or a quote it does not count, so quoting an earlier request is safe. A pull request or issue description does not count. Neither does editing a comment to add the handle: post a new one. - **One run per thread.** While a run is working on that pull request or issue, another mention goes to the same run. Later reviews and CI results on the pull request reach it too. - **Approvals.** The run uses that agent's [permission mode](/docs/human-in-the-loop), so a push can wait for approval in the app. - **Pull requests from a fork.** The App cannot push to a fork's branch. A review or a comment on a line starts nothing there. A plain comment starts a run that answers with review comments instead of pushing. - **Nothing started?** On a pull request the comment reaches the agent named Reviewer, or exactly one other coding agent bound to that repository. Only the Lead mate, or two or more and none is named Reviewer, starts nothing. Name one `Reviewer`. On an issue: the account has no Lead mate. - **Your own GitHub App.** Mention that App's handle instead of `@m8tes-ai`. GitHub's comment box suggests people and teams, never a GitHub App. Type the handle in full. ## Related - [Computer use](/docs/computer-use): desktop/browser in the same sandbox - [Agents](/docs/agents): creating and running agents - [Users](/docs/users): `user_id` isolation - [Human-in-the-loop](/docs/human-in-the-loop): permission modes that gate merges and shell - [API reference](/docs/api-reference): `github-app` and `agents/{id}/repos` # Multi-tenancy Source: https://www.m8tes.ai/docs/users Pass `user_id` on agents, runs, tasks, memories, and permissions when each end-user needs isolated data. Without it, resources are account-level. ## Start with user_id The same `user_id` on every call keeps one customer's world separate: ```python from m8tes import M8tes client = M8tes() bot = client.agents.create(name="customer assistant", tools=["gmail"], user_id="cust_123") run = client.runs.create(agent_id=bot.id, message="summarize open requests", user_id="cust_123", stream=False) print(client.runs.poll(run.id, user_id="cust_123").output) task = client.tasks.create(agent_id=bot.id, instructions="daily support summary", user_id="cust_123") client.memories.create(user_id="cust_123", content="prefers email updates") client.permissions.create(user_id="cust_123", tool="gmail") ``` ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); // reads M8TES_API_KEY const bot = await client.agents.create({ name: "customer assistant", tools: ["gmail"], user_id: "cust_123", }); const run = client.runs.create({ agent_id: bot.id, message: "summarize open requests", user_id: "cust_123", }); console.log(await run.text()); await client.tasks.create({ agent_id: bot.id, instructions: "daily support summary", user_id: "cust_123", }); ``` The TypeScript SDK wraps all of these: `client.agents`, `client.runs`, `client.tasks`, `client.users`, `client.memories`, and `client.permissions`. List endpoints take the same filter, so you only fetch one tenant: ```python agents = client.agents.list(user_id="cust_123") runs = client.runs.list(user_id="cust_123") memories = client.memories.list(user_id="cust_123") tasks = client.tasks.list(user_id="cust_123") ``` ## Usage and billing Isolation is about **data**. For billing, any run carrying a `user_id` settles on your prepaid balance (the API meter), not on your platform plan. See [Billing & Usage](/docs/billing-usage). You can still see and cap each end-user individually: `GET /api/v2/usage/end-users` rolls up each end-user's completed runs, cost, and tokens for the current billing period: ```python for row in client.users.usage().data: print(row.user_id, row.runs_used, row.cost_used, row.total_tokens) alice = client.users.usage("cust_123").data[0] # one end-user ``` Cap every end-user account-wide, or throttle one specific end-user with a per-profile override: ```python client.settings.update(per_end_user_run_limit=50, per_end_user_cost_limit_cents=2000) client.users.update("cust_123", run_limit=5, rate_per_minute=2) # override for one user ``` ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); await client.settings.update({ per_end_user_run_limit: 50, per_end_user_cost_limit_cents: 2000 }); await client.users.update("cust_123", { run_limit: 5, rate_per_minute: 2 }); // override for one user for await (const row of await client.users.usage()) { console.log(row.user_id, row.runs_used, row.cost_used, row.total_tokens); } ```
Cap semantics, error codes, and per-day cost series - Per-profile overrides win over the account-wide defaults. `run_limit=0` blocks an end-user entirely; `None` clears an override back to the account default. - A capped end-user's next run returns `402` with `END_USER_RUN_LIMIT_REACHED` or `END_USER_COST_LIMIT_REACHED`; rate bursts return `429 END_USER_RATE_LIMITED`. Other end-users (and your own runs) are unaffected. - The rollup endpoint reports the same counters and the EFFECTIVE caps per row, so the numbers always reconcile. - For cost attribution over time, `client.billing.usage_timeseries(user_id="cust_123")` returns that end-user's daily token + USD buckets.
## User profiles Profiles are auto-created the first time you use a new `user_id`; manage them directly when you want names and emails attached: ```python user = client.users.create(user_id="cust_456", name="alex lee", email="alex@acme.com", company="acme") user = client.users.get("cust_456") for item in client.users.list().data: print(item.user_id, item.name) client.users.update("cust_456", name="alex kim", company="acme inc") client.users.delete("cust_456") # removes profile fields only; runs, tasks, memories stay ``` ## Strict multi-tenant mode The classic multi-tenancy bug is forgetting `user_id` on one call: the data silently lands in the account-level scope, invisible to your end-users. Strict mode rejects those requests instead, and it is **on by default for new API accounts**: ```python client.agents.create(name="Bot") # 422: This account requires user_id on every agent request (strict multi-tenant mode) # error_code: END_USER_ID_REQUIRED (Python: exc.code) client.agents.create(name="Bot", user_id="cust_123") # scoped: works ``` **Building for just yourself?** Turn it off and drop `user_id` everywhere: ```python # Personal development only: this disables strict user_id checks account-wide. # This persists. For customer-facing apps, keep strict mode on and pass user_id. client.settings.update(require_end_user_id=False) # or sign up with "require_end_user_id": false ```
Where strict mode is enforced, and older accounts Enforced wherever a request scopes data. On **writes**: agent, task, run, skill and custom-MCP-server creation (a run inheriting scope from its scoped agent passes without an explicit `user_id`), plus every memory operation. On **reads**: listing agents, tasks, runs, skills, custom MCP servers, memories or permissions without a `user_id` is rejected rather than answered with a scope you did not ask for. Exempt: app connections, replies to pre-existing unscoped runs, catalogue reads (`apps`, built-in tools; there `user_id` only flags per-end-user availability), and anything you do in the web console. The console is your own session on your own account, so it shows the cross-end-user view; the rule is about a programmatic call that forgot its scope. Accounts created before mid-2026 (and platform-product accounts) default to off; opt in with `client.settings.update(require_end_user_id=True)`.
## Best practices 1. Use your own stable internal ID as `user_id`, and pass it on every relevant request 2. Do not mix scoped and unscoped writes for the same product flow 3. Keep permission policies and memories aligned to the same `user_id` 4. Keep strict mode on so a forgotten `user_id` fails loudly instead of writing to the account scope Next: [Runs](/docs/runs) · [Human-in-the-Loop](/docs/human-in-the-loop) · [API Reference](/docs/api-reference) # Embed a UI Source: https://www.m8tes.ai/docs/embed-a-ui > **Pre-1.0.** `@m8tes/react` is the React UI layer for the API. Install with `npm i @m8tes/react` (`latest` tracks the newest release). Component and hook APIs may change before 1.0; the streaming wire protocol (`m8tes.stream.v2`) stays semver-stable. Prefer the [Python SDK](https://pypi.org/project/m8tes/) when you need full API coverage. Put a live agent in your React app: streaming replies, tool calls, and inline approvals, themed to your product. Two pieces: the component, and a one-file server route so the secret key never reaches the browser. ```tsx "use client"; import { M8tesProvider, MateChat } from "@m8tes/react"; import "@m8tes/react/styles.css"; export function AgentPanel() { return ( ); } ``` ## Install ```bash npm i @m8tes/react ``` The server-only handler lives behind a separate `@m8tes/react/server` import, so the secret can never leak into a client bundle. Prefer to own the source? The shadcn registry copies the component into your repo: ```bash npx shadcn@latest add https://www.m8tes.ai/r/mate-chat.json ``` ## Add the server route (your key stays server-side) There's no browser-safe key, so a route on **your** server holds the `m8_` secret and forwards stream requests. In Next.js (App Router), one file does it: ```tsx // app/api/m8tes/[...path]/route.ts import { auth } from "@clerk/nextjs/server"; // or your existing server auth import { createM8tesHandler } from "@m8tes/react/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 300; // long-running streams export const { GET, POST } = createM8tesHandler({ apiKey: process.env.M8TES_API_KEY!, // secret, server-side only resolveUserId: async () => (await auth()).userId, // signed-in user → isolated agent context agentId: process.env.M8TES_AGENT_ID, // recommended: pin embed runs to ONE mate // ...or, when each end-user has their OWN mate (required once the agent acts on your // API as that user), pin per request instead: // resolveAgentId: async ({ userId }) => lookupAgentFor(userId), }); ``` The Clerk example assumes Clerk is already configured in your app; adapt `resolveUserId` to your existing server auth using the examples below. Set `M8TES_API_KEY` in `.env.local` and sign in before sending a message. The provider defaults to `/api/m8tes`; set its `endpoint` prop if you mount the proxy elsewhere. The handler is **locked down by design**: it forwards only the run-scoped endpoints and 403s everything else, strips any client-supplied `user_id` and injects the one `resolveUserId` returns, and drops `permission_mode`, `tools`, `instructions`, and `model` from every body. `resolveUserId` is **required** (fail-closed; `null` → 401): pass `{ singleTenant: true }` only if every user should share one scope.
Wiring your auth: Clerk, Auth.js, Supabase, Express `resolveUserId` receives the incoming request, so map it to your authenticated user however you already do. Never derive the user id from a client-supplied field: the proxy strips it precisely so the browser can't spoof another user. ```tsx // Clerk import { auth } from "@clerk/nextjs/server"; resolveUserId: async () => (await auth()).userId, // Auth.js / NextAuth v5 resolveUserId: async () => (await auth())?.user?.id ?? null, // Supabase (SSR): read the session from your server client // Custom JWT: verify your own cookie/header and return the subject ``` Non-Next frameworks (Express, Hono, Remix) use `createM8tesProxy`, which returns a framework-agnostic `(Request) => Promise` handler you adapt to your server. Full adapters: `auth-adapters.md` inside the package.
## Render the agent: `` `` is the full panel: thread, composer, streaming markdown, tool calls, and approval / question gates. It parses the V2 SSE wire, reconnects on drops, and de-duplicates replay ([Streaming](/docs/streaming)). ```tsx ``` When a run pauses for approval or a question, an inline card appears; the answer resumes the run. Pausing follows the **mate's** permission mode. The proxy never lets the browser set it. After a failed, incomplete, or cancelled turn, **Retry** re-sends the last message on the same run when one was created. Retry is available only for a message sent in the current browser session; a restored run has no local message to replay. Temporary connection drops reconnect automatically; if they persist, **Reconnect** rejoins the current run without sending duplicate work. Temporary decision-submission failures keep the card available to retry. **First-message latency:** the first message of a session boots the sandbox (a few to tens of seconds); follow-ups are warm. `` shows **"Connecting…"** during boot; `classNames.statusBar` restyles that row. **Threads across a refresh.** Save new IDs with `onRunIdChange` and pass a `runId` to restore the conversation. Keep saved IDs scoped to the signed-in user. If a completed run can no longer stream, the panel restores its final output; this fallback is not a full historical transcript. For a thread list, call `client.runs.list(user_id=…)` from your backend and return the IDs that user may open. The browser proxy deliberately does not expose run listing. ## Floating chat: `` Use the same provider and stylesheet with a launcher and responsive panel: ```tsx import { M8tesProvider, MateWidget } from "@m8tes/react"; import "@m8tes/react/styles.css"; ``` Closing the widget or returning home keeps the conversation, draft, and stream. Reopen and choose **Continue conversation**. Pass `runId` to restore a saved thread, `loading` or `error` for server session setup, and change the component's React `key` to start fresh (including when the signed-in user changes). The widget fills the screen on phones. The panel version, `MateChat`, needs a height from your layout or its `style` prop. ## Make it yours Four levels, lightest first; most apps stop at level 1 or 2. **1. Theme tokens.** The chat reads your shadcn/Tailwind variables first and falls back to the m8tes palette, so in a shadcn app it matches your product automatically. Override anywhere above the chat: ```css .my-agent-panel { --primary: oklch(0.55 0.2 264); /* buttons, user bubble, links */ --radius: 0.75rem; --font-sans: "Inter", sans-serif; /* the embed inherits your font */ } ``` **2. `classNames`.** Restyle individual slots: `` **3. `components`.** Swap any rendered piece: bring your own avatar, tool-call card, approval dialog, or markdown renderer. **4. Headless.** Skip `` and drive the hook; you render the rest: ```tsx import { useMate } from "@m8tes/react"; const { messages, status, isStreaming, files, pendingApproval, approve, // approval gate pendingQuestion, answer, // question gate. Without answer, a paused run never resumes send, canSend, // gate your composer on canSend stop, retry, canRetry, // gate Retry on canRetry, not on status isStopping, stopError, // cancellation acknowledgement and retryable failure isDisconnected, connectionError, resume, // rejoin after a drop, without resending } = useMate({ agentId, runId }); ``` `stop()` requests cancellation; disable actions while `isStopping` is true. If it fails, show `stopError` and let the user try Stop again while the hook rejoins the active run. For custom decision cards, await `approve()` / `answer()` and catch rejections before showing a decision as applied. When automatic reconnects run out, `isDisconnected` goes true and `canSend` goes false: the server may still be executing that run, so `resume()` (or `retry()`) rejoins it instead of re-sending the prompt. `connectionError` carries the reason to show. The package ships a **Customizing ``** reference at `docs/customizing.md` (open it from `node_modules/@m8tes/react/docs/customizing.md` after install) with the full token table, dark mode (works under a `.dark` ancestor), the `--m8-*` state-accent tokens, the Tailwind v3 bridge, and every slot and component override. ## Security model (multi-tenant) The proxy enforces: the key never reaches the browser; only the run-scoped endpoints are forwarded (a compromised session can't reconfigure your account); `user_id` is injected server-side so user A can never read user B's runs; and no policy escalation from the browser. What stays yours: 1. **Pin the mate**: `agentId` for one shared mate, `resolveAgentId` for one mate per end-user (shown in the route snippet). Without a pin, an end-user can start runs on any non-Company-Agent mate in your account. 2. **Scope the mate's connections to the end-user, not the account** ([per-user OAuth](/docs/tools#end-user-connections-multi-tenant-oauth)). An account-level connection (your Gmail, your Stripe) means a prompt-injected end-user could act *on your behalf*; in an embed, the person clicking Approve **is the end-user**. 3. **Rate-limit in your route** (every message is a run billed to your account) and **cap your spend** with a [usage overage ceiling](/docs/billing-usage) plus per-end-user sub-caps ([per-user overrides](/docs/users#usage-and-billing)); when an end-user hits one, `` shows a "limit reached" card. 4. **Rotate the key** from the [Developer dashboard](https://www.m8tes.ai/developer) if you suspect exposure.
Rate-limit example (in resolveUserId) ```ts import { auth } from "@clerk/nextjs/server"; // or your existing server auth import { createM8tesHandler } from "@m8tes/react/server"; export const { GET, POST } = createM8tesHandler({ apiKey: process.env.M8TES_API_KEY!, resolveUserId: async (req) => { const userId = (await auth()).userId; // isRateLimited: your limiter (e.g. Upstash Ratelimit) if (!userId || (await isRateLimited(userId))) return null; // null => 401, run never starts return userId; }, }); ```
Troubleshooting | Symptom | Cause | Fix | |---|---|---| | Every message returns **401** | `resolveUserId` returned `null` (no signed-in user) | Confirm your auth resolves server-side; for an internal tool use `{ singleTenant: true }` and turn strict mode off (`require_end_user_id: false`), or every run 422s with `END_USER_ID_REQUIRED`. | | **404** on a run you just created | The run belongs to a different end-user, or the id is wrong | The proxy scopes every run to `resolveUserId`; make sure the same user is resolved across create + stream. | | First message hangs on **"Connecting…"** for 30s+ | Cold sandbox boot | Expected on the first message of a session; follow-ups are warm. Re-skin the connecting status so it reads as progress. | | Stream **never streams** behind nginx / a CDN | The proxy response is being buffered | The handler sets `Cache-Control: no-transform` + `X-Accel-Buffering: no`; ensure your edge honors them and doesn't buffer `text/event-stream`. | | Installed version looks **older than the docs** | An old lockfile pinned a stale prerelease | Reinstall with `npm i @m8tes/react@latest`, or copy the source via the shadcn registry: `npx shadcn@latest add https://www.m8tes.ai/r/mate-chat.json`. | | **403** on a request | The path isn't run-scoped | The proxy only forwards `/runs` + run sub-routes by design; everything else is 403. |
Next: [Streaming & Events](/docs/streaming) · [Human-in-the-Loop](/docs/human-in-the-loop) · [Users](/docs/users) · [Quick Start](/docs/quickstart) # Your API as MCP tools Source: https://www.m8tes.ai/docs/your-api-as-mcp-tools **You give us REST endpoints; we run the MCP server.** Your users' agents connect to it. Every call to your API is stamped with the end user it's acting for. No MCP server for you to build or host. No per-user credential for you to collect. The shape, once: 1. **One service token.** Your API already authorizes your users; it needs to know *which* one. m8tes calls your API with a token you issue us and stamps the end-user id on every request. 2. **One tool row per end-user**, all carrying that same token. The row's scope becomes the identity we send. 3. **One mate per end-user**, holding that row. 4. **The proxy pins the mate per request**, so each user lands on their own. > Already host your own MCP server? Point us at it with `kind="mcp_http"`. We connect out and discover its tools. The end-user identity header below is **not** sent on that path, so a remote MCP server must carry its own per-customer scoping (one server per customer, or identity in its own URL/credential). This page covers the common case: a REST API and no MCP server. > `kind="script"` (a small Python tool) cannot take `user_id`. For one agent per customer, stay on `rest_api`. See [Custom tools](/docs/custom-tools#kinds). ## 1. Wrap your API as a tool A tool def is a method, a path, and a description. m8tes synthesizes the MCP tools for you. ```python from m8tes import M8tes client = M8tes() # reads M8TES_API_KEY TOOL_DEFS = [ {"name": "list_orders", "method": "GET", "path": "/orders", "description": "List this customer's recent orders."}, {"name": "update_shipping", "method": "POST", "path": "/orders/{id}/shipping", "description": "Change the shipping address on an order."}, ] ``` ## 2. Provision per end-user, at signup Run this once per customer, wherever you already create their account. Both calls take the same `user_id`. That pairing is the part that matters (see [Scope pairing](#scope-pairing)). ```python def provision(customer_id: str): tool = client.mcp_servers.create( name="acme api", url="https://api.acme.com/v1", auth_type="bearer", secret=ACME_SERVICE_TOKEN, # the SAME token for every customer tool_defs=TOOL_DEFS, user_id=customer_id, ) agent = client.agents.create( name="acme assistant", instructions="Help the customer with their orders. Confirm before changing anything.", tools=[tool.slug], user_id=customer_id, ) return agent.id # store this against the customer in your DB ``` Slugs and the per-account tool quota are scoped **per end-user**, so every customer can hold the same `acme-api` slug. The token is stored encrypted, once per row, and never reaches the agent or the sandbox. ## 3. Pin the mate per request > **Requires `@m8tes/react@0.1.0-alpha.2` or later.** On an older build, `resolveAgentId` is silently ignored, no mate is pinned, and the browser's own `teammate_id` wins. That is the hole pinning exists to close. Run `npm i @m8tes/react@latest` to upgrade, and check `M8TES_REACT_VERSION` if you're unsure what you have. Each customer has their own mate, so a single static `agentId` no longer works. It would send everyone to one customer's mate, and the API would reject the mismatch. Resolve it per request instead: ```ts // app/api/m8tes/[...path]/route.ts import { createM8tesHandler } from "@m8tes/react/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 300; export const { GET, POST } = createM8tesHandler({ apiKey: process.env.M8TES_API_KEY!, resolveUserId: async (req) => (await auth(req)).userId, resolveAgentId: async ({ userId }) => db.agents.findByUser(userId), }); ``` `resolveAgentId` is handed the **already resolved** `userId`. Key your lookup off that, never off a query param or a readable cookie. Otherwise the browser is choosing the mate again. It fails closed: throw or return nothing and the request 401s rather than starting an unpinned run. See [Embed a UI](/docs/embed-a-ui) for the rest of the proxy. ## 4. What your API receives Every tool call arrives from m8tes' servers (never the browser, never the sandbox) as a plain HTTPS request: ```http GET /v1/orders HTTP/1.1 Host: api.acme.com Authorization: Bearer X-M8tes-End-User-Id: cust_123 ``` Authorize it exactly as you would a logged-in session for `cust_123`: ```python @app.get("/v1/orders") def list_orders(request): assert_service_token(request.headers["Authorization"]) # it's us customer = lookup(request.headers["X-M8tes-End-User-Id"]) # it's for them return orders_for(customer) # your existing rules ``` **Trust basis:** the identity header is a claim; the service token makes it trustworthy (only m8tes holds it). Two rules, both on you: 1. **Authorize on the token, every request.** Never expose an endpoint that honours `X-M8tes-End-User-Id` without verifying the credential first. The header says *who we're acting for*, not *that we're allowed to*. 2. **Treat a missing header as "no end-user", not as "any end-user".** Reject or fall back to a safe default. Never to an admin scope. We send the header **only** on servers configured with real auth (an `auth_type` other than `none`, with a secret). On an unauthenticated server it is omitted rather than left forgeable. It is also omitted, with a warning logged, when a server's own credential header is named `X-M8tes-End-User-Id`. The credential always wins that collision, and new servers can't be configured that way. The value is **percent-encoded**, so any id transmits safely as a header. Ordinary ids (`cust_123`, a UUID) are unchanged; if yours can contain non-ASCII or spaces, URL-decode before you look it up. Your API stays the authority. m8tes never assumes an id grants access. If `cust_123` may not see an order, return your usual 403 and the agent will tell them so. ## Scope pairing **A tool resolves only when the RUN's `user_id` matches the tool's.** No fallback to account-level data in either direction: | Run's `user_id` | Tool scope | Result | |---|---|---| | `"cust_123"` | `user_id="cust_123"` | Works | | `"cust_123"` | account-level (no `user_id`) | **No tools** | | none (account-level run) | `user_id="cust_123"` | **No tools** | A mate scoped to `cust_123` only ever runs as `cust_123`, so pairing it with that customer's tool can't drift. An **account-level** mate has no fixed scope: its runs carry whatever `user_id` you pass, so an end-user tool attached to it still resolves on that end-user's runs only. That works, but every caller must pass the right `user_id`. One omission silently yields no tools. A scope mismatch silently leaves the agent without tools. Check: 1. The run's `user_id` matches the intended user. 2. A `GET` on the mate lists the expected slug in `tools`. Account health checks flag attachments that can never resolve, such as another user's tool on a user-scoped mate. They allow the legitimate account-level case above. Two more constraints: - Custom slugs attach to an **agent** only. Passing one in a per-task or per-run `tools` list is rejected. - **`GET` tools never prompt for approval**, in any permission mode. Writes do, unless the server is marked trusted. See [Take Actions](/docs/take-actions). ## Limits Calls are capped at **120 per minute per end-user, per server**. The budget covers a server's whole tool set, not each tool separately. One runaway agent or abusive customer therefore can't burn the API quota everyone else shares through your service token. A throttled call returns a `rate_limited` error carrying `retry_after` to the agent, which backs off; it never fails the run. ## Rotating the token The token is stored per row, so rotation is a sweep: ```python for customer_id in all_customers(): for s in client.mcp_servers.list(user_id=customer_id): client.mcp_servers.update(s.id, secret=NEW_TOKEN, user_id=customer_id) ``` Issue the new token before revoking the old one so in-flight runs don't fail mid-call. Next: [Embed a UI](/docs/embed-a-ui) · [Multi-tenancy](/docs/users) · [Take Actions](/docs/take-actions) · [Human-in-the-Loop](/docs/human-in-the-loop) # Take actions Source: https://www.m8tes.ai/docs/take-actions An agent that operates your product for one end-user through your REST API. A customer types "refund my last invoice"; the agent looks it up and issues the refund through your API, pausing for a human on the write. Three moves: - **Your product becomes the agent's tools.** Describe REST endpoints once ([custom tools](/docs/custom-tools)). m8tes calls them server-side; the secret never reaches the agent. - **It acts as one end-user.** Pass `user_id` ([Users](/docs/users)). Isolation is strict. - **Writes wait for a human.** Custom tools ask for approval until you mark the server trusted. ## Wrap your REST API Each endpoint you describe becomes a tool the agent can call by name: ```python from m8tes import M8tes client = M8tes() server = client.mcp_servers.create( name="acme billing", url="https://api.acme.com/v1", auth_type="bearer", secret="sk-live-...", # write-only, never returned, never seen by the agent tool_defs=[ {"name": "get_invoice", "method": "GET", "path": "/invoices/{id}"}, {"name": "create_refund", "method": "POST", "path": "/refunds"}, ], user_id="cust_123", ) print(server.slug) # "acme-billing" ``` Put `{param}` in a path to make it an argument (`/invoices/{id}`); any other arguments the agent passes go to the query string (GET/DELETE) or the JSON body (POST/PUT/PATCH). Auth methods and path rules: [Custom tools](/docs/custom-tools). ## Scope it to an end-user If every customer hits your API with the same key, keep one account-level server and scope at the agent and run (next section). If each customer has their own credentials, create one server per customer: ```python server = client.mcp_servers.create( name="acme billing", url="https://api.acme.com/v1", auth_type="bearer", secret=customer_api_key, # this customer's own key user_id="cust_123", # visible only under this end-user tool_defs=[{"name": "get_invoice", "method": "GET", "path": "/invoices/{id}"}], ) ``` For your customers' Gmail, Slack, and other catalog apps, use per-end-user OAuth instead ([Tools](/docs/tools)). ## Attach it and run it Reference the server's `slug` in an agent's `tools`, then run for a customer: ```python ops = client.agents.create(name="billing ops", tools=["acme-billing"], user_id="cust_123") run = client.runs.create( agent_id=ops.id, message="refund invoice 8842", user_id="cust_123", stream=False, ) print(client.runs.poll(run.id, user_id="cust_123").output) ``` The agent sees the tool as `mcp__cmcp-__create_refund`; you only ever use the slug. Custom slugs attach to an **agent**: unlike catalog tools, they can't be passed per task or per run (a run-level `tools` list containing a custom slug is rejected). Give the agent its custom tools once, at create or update. ## Keep a human in the loop **Read-only (`GET`) tools run without asking**, in every permission mode. They can't change anything. **Writes ask before each use.** To let a write run unattended, mark its server trusted: ```python # writes you're happy to run unattended (scheduled, webhook, API runs) client.mcp_servers.update(writes.id, auto_approve=True, user_id="cust_123") ``` An untrusted write pauses for approval; in an unattended run with no one to approve, it is marked needs-approval and never silently fires. Handle approvals inline with `runs.wait(...)`, or from your inbox or Slack. See [Human-in-the-loop](/docs/human-in-the-loop). **What's guaranteed** ([full detail](/docs/custom-tools)): egress is server-side and IP-pinned (the agent never connects to your service directly; private and metadata addresses are blocked); your secret is encrypted at rest and never reaches the agent; a server is visible only to its owner and, when scoped, its `user_id` end-user.
End-to-end: refund an invoice with approval A billing product lets its agent look up invoices on its own but requires a human to approve refunds, all for one customer: ```python from m8tes import M8tes client = M8tes() # reads M8TES_API_KEY # read-only lookups → GET tools never prompt, so no auto_approve needed lookups = client.mcp_servers.create( name="acme billing reads", url="https://api.acme.com/v1", auth_type="bearer", secret="sk-live-...", tool_defs=[{"name": "get_invoice", "method": "GET", "path": "/invoices/{id}"}], user_id="cust_123", ) # refunds → untrusted, every call waits for a human writes = client.mcp_servers.create( name="acme billing writes", url="https://api.acme.com/v1", auth_type="bearer", secret="sk-live-...", tool_defs=[{"name": "create_refund", "method": "POST", "path": "/refunds"}], user_id="cust_123", ) ops = client.agents.create( name="billing ops", tools=[lookups.slug, writes.slug], user_id="cust_123", ) run = client.runs.create( agent_id=ops.id, message="invoice 8842 was double-charged. refund it.", user_id="cust_123", human_in_the_loop=True, permission_mode="approval", stream=False, ) run = client.runs.wait(run.id, on_approval=lambda req: "allow", user_id="cust_123") print(run.output) ``` The lookup ran on its own; the refund paused for `on_approval`. Surface `req.tool_input` in your UI so a human can decide before you return `"allow"` or `"deny"`.
Next: [Your API as MCP tools](/docs/your-api-as-mcp-tools) · [Custom tools](/docs/custom-tools) · [Users](/docs/users) · [Human-in-the-loop](/docs/human-in-the-loop) · [Going live](/docs/going-live) # Human-in-the-loop Source: https://www.m8tes.ai/docs/human-in-the-loop Three mechanisms pause a run for a human: 1. **AskUserQuestion**: the agent asks a question and waits for an answer. 2. **Tool approval**: the agent waits for allow or deny before a gated tool call. 3. **Plan mode**: the agent proposes a plan and waits for approval before executing. Runs inherit their permission mode from the explicit request override or the saved agent/task default. Turn on `human_in_the_loop` when you want checkpoints before the agent continues. ## Quick start `runs.wait()` handles all three inline, no manual polling loop: ```python from m8tes import M8tes client = M8tes() run = client.runs.create(agent_id=1, message="draft and send a weekly customer update", human_in_the_loop=True, permission_mode="approval", stream=False, user_id="cust_123") run = client.runs.wait( run.id, on_question=lambda req: {"Which segment?": "enterprise"}, # answer questions on_approval=lambda req: "allow", # approve tool calls user_id="cust_123", ) print(run.output) ``` Both callbacks receive the full `PermissionRequest`: inspect `req.tool_name` and `req.tool_input` (for questions, `req.tool_input["questions"]` is the full list) before returning your decision. `permission_mode="approval"` only pauses for tools that are not on the safe allowlist. Safe reads (and the Agent/Task spawn tools) do not raise `awaiting_approval`, so `on_approval` will not fire for those. If the agent stalls without a pending permission, `create_and_wait` / `reply_and_wait` raise `TimeoutError` and best-effort cancel the run (`cancel_on_timeout=True`); bare `wait` / `poll` observe only unless you opt in. ## Permission modes | Mode | What happens | |------|--------------| | `autonomous` | Agent runs without approval pauses | | Auto | Agent handles routine, vetted work itself and pauses for high-stakes tool calls. The API value stays `approval` | | `plan` | Agent proposes a plan first and waits for approval | If a run resolves to `approval` or `plan` and you omit `human_in_the_loop`, the API enables it automatically. Explicitly setting `human_in_the_loop=False` with those modes returns a validation error. ## AskUserQuestion Answers always go through `runs.answer()`. The answer key is the question text: ```python client.runs.answer(run.id, answers={"Which customer segment should I prioritize?": "enterprise"}) ``` Behavior depends on how the agent framed the question: | Question | Behavior | |----------|----------| | Has a recommended option (label ending in `(Recommended)`) | Pauses to `awaiting_approval`; unanswered after ~10 minutes, the run **auto-continues with the recommendation** and notifies the owner | | Recommended + non-blocking (`metadata.source == "non-blocking"`) | Never pauses; proceeds with the recommendation immediately and notifies the owner | | No recommended option | Pauses indefinitely (a question the agent genuinely can't default) | Tool approval gates and plan approvals never auto-continue.
Advanced: auto-continue markers, 409s, and finding pending questions - If the run is `awaiting_approval`, your answer resumes the run. - If the run is terminal (`completed`, `failed`, `cancelled`), `runs.answer()` returns `409`. - If the question already auto-continued, `runs.answer()` returns `409` with `auto_continued`; send a follow-up via `runs.reply()` to redirect. - Auto-continued requests have `auto_resolved == True` and their answer values in `tool_input["answers"]` end with an `[auto-selected ...]` marker, so you can always tell a platform default from a human answer. - Non-blocking asks never enter `awaiting_approval`; deferrable ones pause up to ~10 minutes, then resume on their own. A run can complete with questions nobody answered. - A blocking pause unanswered for a few minutes on a first-party run triggers a "waiting on you" email to the owner, or a Slack DM when Slack is connected (skipped for multi-tenant `user_id` runs). A present user who answers in-app never triggers it. Each person picks email or Slack for it in Settings → Preferences. The API can still silence both: `client.settings.update(approval_email_enabled=False, approval_slack_enabled=False)`. Find pending questions with `runs.permissions()`. Entries have `tool_name == "AskUserQuestion"` and `tool_input["questions"]` with the full question list: ```python for req in client.runs.permissions(run.id): if req.tool_name == "AskUserQuestion" and req.status == "pending": for q in req.tool_input["questions"]: # q["question"] is the answer key; q["options"] is the list of choices print(q["question"], [o["label"] for o in q["options"]]) client.runs.answer(run.id, answers={"Which customer segment should I prioritize?": "enterprise"}) ```
## Tool approval Fetch pending requests with `runs.permissions()`, then decide with `runs.approve()`: ```python for req in client.runs.permissions(run.id): if req.status == "pending": client.runs.approve(run.id, request_id=req.request_id, decision="allow", # or "deny" remember=True) # also stores a cross-run always-allow policy ``` With `decision="allow"`, `remember=True` allows matching requests for this run and saves an always-allow policy for future runs. | Response | What to show | |---|---| | `status="allowed"`, `remembered=true` | “Always allowed” | | `status="allowed"`, `remembered=false` | Allowed for now; policy was not saved | | `status="denied"` | Denied | | `status="pending"` | Waiting for a decision | | `remembered=null` | Older record; saved-policy outcome unknown | `GET /runs/{id}/permissions` and `permission_resolved` events carry the saved outcome. A later event can confirm persistence; update the UI when it arrives. Strict human-decision tools such as `decide_value_observation` always ask again and omit the remember control. To save a policy before running, see [pre-approve trusted tools](#pre-approve-trusted-tools).
Approve from your inbox or Slack A paused run (a tool gate or an AskUserQuestion) can be answered without opening the app: - **Email**: reply to the pause email with the option number or option name (or `yes`/`no` for a tool gate). A bare `yes`/`no` to a question is dropped. - **Slack**: when the run originated in Slack, click **Approve**/**Deny** for a tool gate or the per-option button for a single-question AskUserQuestion in the thread. The clicker must be the installer or an allowlisted Slack id. Email is the universal fallback when a Slack prompt can't deliver, unless you turned approval emails off. These are the human-friendly equivalents of `runs.approve()` (tool gates) and `runs.answer()` (AskUserQuestion).
Switch permission mode mid-run Change the permission mode on a running or paused run without restarting it: ```python client.runs.update_permission_mode(run_id=42, permission_mode="autonomous") client.runs.update_permission_mode(run_id=42, permission_mode="approval") ``` Switching to `autonomous` auto-approves pending tool approval requests and resumes a paused tool approval run immediately. `AskUserQuestion` and plan approvals still wait for `client.runs.answer()`.
### Pre-approve trusted tools Create per-user permission policies so trusted tools skip approval pauses across runs. Pre-approve read-only tools (search, fetch, list) and keep write/send/delete actions gated: ```python client.permissions.create(user_id="cust_123", tool="slack") client.permissions.create(user_id="cust_123", tool="linear") ``` Omit `user_id` to pre-approve a tool at the **account level**: the scope runs match when they carry no `user_id`. This is also where "always allow" decisions made during a run are stored, so it is the list to check when you want to see or revoke standing approvals: ```python # Personal development only: this disables strict user_id checks account-wide. # This persists. For customer-facing apps, keep strict mode on and pass user_id. client.settings.update(require_end_user_id=False) client.permissions.create(tool="slack") # applies to runs with no user_id for policy in client.permissions.list(): # standing account-level approvals client.permissions.delete(policy.id) # revoke one ``` In Auto or Plan mode, reads run without asking and anything with real consequences in a connected app (creating, editing, or deleting records, and messages to other people such as an email or Slack post) asks a human — except explicitly allowlisted reversible drafts such as Gmail draft creation, which auto-approve like reads (other account drafts, including Outlook, still ask). High-stakes actions (money, access, destroy, sends) always ask. Autonomous mode and an explicit Always-allow (approval card tick, or `POST /permissions`) supply standing grants; hard blocks, explicit denials, and strict human-decision tools still take precedence. Treat pre-approving one as deliberate. Every policy records `source` (`run_approval` or `api`). Routine [task setup](/docs/built-in-tools#task-setup), including attaching an already-connected app and pausing or resuming a schedule, auto-approves without a stored policy.
Onboarding pattern Set policies at user creation time so the agent isn't interrupted constantly. Low-risk candidates: `slack`, `linear`, `googlesheets`, `gmail`, `notion`. ```python def onboard_user(user_id: str): client.users.create(user_id=user_id) for tool in ["slack", "linear", "googlesheets"]: client.permissions.create(user_id=user_id, tool=tool) ```
## Plan mode The agent proposes a plan and waits for approval before executing. It has its own page: [Plan Mode](/docs/plan-mode). ## Production pattern: webhooks Prefer webhooks over polling: subscribe to `run.awaiting_input`, show the pause, send the decision with `answer` or `approve`. AskUserQuestion pauses include questions in the payload; tool approval pauses use `runs.permissions()`. See [Webhook Events](/docs/webhooks-guide).
Sample run.awaiting_input payload ```json { "type": "run.awaiting_input", "data": { "id": 42, "status": "awaiting_approval", "questions": [ { "question": "Which customer segment should I prioritize?", "header": "Segment", "multiSelect": false, "options": [ { "label": "enterprise", "description": "Large orgs (500+ seats)" }, { "label": "mid-market", "description": "Growing teams" } ] } ] } } ```
Replies on human-in-the-loop runs `runs.reply()` inherits the run's persisted settings: the permission mode keeps applying on follow-ups, and AskUserQuestion stays enabled when the run was created with `human_in_the_loop: true` (or a mode that defaults it on). Two consequences: - **Unattended reply loops**: a reply on a HITL run can pause on a question. If your server-side code replies without a human watching, pass `human_in_the_loop: false` on the reply to pin the always-non-interactive behavior. Runs created before this setting was persisted stay non-interactive. - **Handling gates on follow-ups**: use `reply_and_wait()` to answer approvals and questions inline: ```python run = client.runs.reply_and_wait( run.id, message="also break it down by region", on_approval=lambda req: "allow", user_id="cust_123", ) print(run.output) ```
Next: [Runs](/docs/runs) · [Users](/docs/users) · [Tools](/docs/tools) · [Webhook Events](/docs/webhooks-guide) # Plan mode Source: https://www.m8tes.ai/docs/plan-mode With `permission_mode="plan"`, the agent proposes a plan and waits for approval before executing. Approve or ask for a revision. This is one of three [human-in-the-loop](/docs/human-in-the-loop) mechanisms. ## Approve a plan Plan approval arrives as an AskUserQuestion titled `Plan Approval`. Use `req.is_plan_approval` and `req.plan_text` to detect and display it: ```python from m8tes import M8tes client = M8tes() def handle_plan(req): if req.is_plan_approval: print(req.plan_text) # show the proposed plan return {"Plan Approval": "Approve"} # or "Revise" return {} run = client.runs.create(agent_id=1, message="reorganize open support incidents", human_in_the_loop=True, permission_mode="plan", stream=False, user_id="customer_42") # wait() handles the plan pause and then continues with approval-style checks run = client.runs.wait(run.id, on_question=handle_plan, on_approval=lambda req: "allow", user_id="customer_42") print(run.output) ``` After approval, execution continues with approval-style checks for non-safe tools. Plan approvals never auto-continue: the run waits for a decision. See `examples/plan-mode.py` for a complete interactive example.
Embed plan approval in your product (polling flow) Fetch the plan text and show it to your user before answering: ```python import time run = client.runs.create(agent_id=1, message="...", human_in_the_loop=True, permission_mode="plan", stream=False, user_id="customer_42") while run.status == "running": # poll until the plan is ready time.sleep(2) run = client.runs.get(run.id, user_id="customer_42") for req in client.runs.permissions(run.id): # get the plan text if req.is_plan_approval and req.status == "pending": plan_text = req.plan_text # show plan_text in your UI, collect the decision, then answer and finish client.runs.answer(run.id, answers={"Plan Approval": "Approve"}) run = client.runs.poll(run.id, user_id="customer_42") print(run.output) ``` Prefer webhooks over polling in production: subscribe to `run.awaiting_input` and answer from your UI. See [Human-in-the-Loop](/docs/human-in-the-loop#production-pattern-webhooks).
Next: [Human-in-the-Loop](/docs/human-in-the-loop) · [Runs](/docs/runs) # Testing Source: https://www.m8tes.ai/docs/testing Runs have real side effects (Slack, email, ad changes) and every live run costs tokens. Integration tests should never hit the live API. `m8tes.testing` (SDK 4.8.0+) runs your code against the **real client** with only the network swapped out: ```python from m8tes.testing import MockM8tes, StreamBuilder, agent_payload client = MockM8tes() # real M8tes wired to a mock transport. Nothing reaches the network client.mock.add("POST", "/agents/", json=agent_payload(id=7, name="Ops mate")) client.mock.add("POST", "/runs/", stream=StreamBuilder().metadata(run_id=42).text("Hello world").done()) agent = client.agents.create(name="Ops mate", user_id="tenant-a") with client.runs.create(agent_id=agent.id, message="Do X", user_id="tenant-a") as stream: for event in stream: # real typed events (TextDeltaEvent, DoneEvent, ...) ... assert stream.text == "Hello world" client.mock.calls # every request sent, with parsed JSON bodies ``` The mock is mounted at the transport layer: retries, idempotency keys, typed errors, and SSE parsing are the production paths. No extra deps, no running server. ## Test your failure paths The first thing worth testing is what your code does when a run fails. Build a failing stream, or a full error envelope: ```python import pytest from m8tes import BillingError, RunFailedError from m8tes.testing import MockM8tes, StreamBuilder, error_envelope # A run that fails upstream mid-stream: client = MockM8tes() client.mock.add("POST", "/runs/", stream=StreamBuilder().metadata(run_id=42) .error("Sandbox quota exhausted", error_code="SANDBOX_QUOTA_EXHAUSTED") .done()) with pytest.raises(RunFailedError): list(client.runs.create(agent_id=7, message="Do X", raise_on_error=True, user_id="tenant-a")) # An API error before the run starts. Raises the same typed exception prod raises: client = MockM8tes() client.mock.add("POST", "/runs/", status=402, json=error_envelope( "Balance depleted.", status=402, error_code="TOKEN_BALANCE_DEPLETED", details={"topup_url": "https://www.m8tes.ai/developer"})) try: client.runs.create_and_wait(agent_id=7, message="Do X", user_id="tenant-a") except BillingError as e: assert e.error_code == "TOKEN_BALANCE_DEPLETED" ``` > Fixtures for the same route are consumed **in order** (that's how you script a status poll), so use a fresh client or distinct routes per scenario. Mocked `429`/`500` responses are retried by the real client with real backoff (~1.5s): that's the production retry path doing its job, so prefer 4xx fixtures and assert on `client.mock.calls` counts instead of timing. ## Use your own client construction If your app builds its own `M8tes` (from config, per tenant, ...), install the transport into it instead of using `MockM8tes`: ```python from m8tes import M8tes from m8tes.testing import MockTransport client = my_app.build_client() # your code, unchanged mock = MockTransport() mock.install(client) mock.add("GET", "/agents/7", json=agent_payload(id=7)) ``` ## Pin the tenant scope A query string on a fixture makes it query-aware: the request must carry those parameters, so a test whose code drops `user_id` **fails** instead of silently matching. `mock.calls` records the parsed query as `params` for direct assertions. This is the test that keeps [multi-tenant isolation](/docs/users) honest: ```python client = MockM8tes() client.mock.add("GET", "/agents/?user_id=tenant-a", json=page_payload(agent_payload())) client.agents.list(user_id="tenant-a") # matches assert client.mock.calls[-1].params["user_id"] == "tenant-a" client.agents.list() # AssertionError. The scope went missing ``` Fixtures without a query string match any query. Recorded headers are credential-redacted (`Authorization` becomes ``), so snapshots and CI logs never carry a real key. ## Payload factories `agent_payload`, `run_payload`, `task_payload`, and `page_payload` build realistic v2 wire shapes with sensible defaults. Override only what your test cares about. `error_envelope` builds the [standard error format](/docs/api-errors), including `error_code`. For end-to-end verification against a real backend before going live, use a separate account with a small balance. See [Going Live](/docs/going-live). # Going live Source: https://www.m8tes.ai/docs/going-live Production checklist: keep one end user from draining your budget, exhausting compute, or seeing another end user's data. ## The multi-tenancy boundary Two identities, not the same thing: | Identity | What it is | Set by | |----------|------------|--------| | Your **account** | The auth boundary. One m8tes account, one API key. | Your API key (`m8_...`) | | Your **end-user** | The data-isolation boundary inside your account. | The `user_id` field on a resource | Every agent, run, task, and memory created with a `user_id` is scoped to that end user. Reads are **strict**: `user_id="alice"` sees only alice's data, never bob's, and never account-level data created with no `user_id`. No fallback. No opt-out. ```python from m8tes import M8tes client = M8tes() # reads M8TES_API_KEY # Each of your users gets isolated runs by passing their id as user_id. for event in client.runs.create( agent_id=bot.id, message="Summarize my open tickets", user_id="alice", # → isolated to this end-user ): ... ``` ## Cap each end-user so one can't drain the account Without a per-end-user cap, one end user can consume the whole account budget. Set sub-caps: ```python client.settings.update( per_end_user_run_limit=50, # ≤ 50 runs per end-user per billing period per_end_user_cost_limit_cents=2000, # ≤ $20.00 metered cost per end-user ) # Pass None to clear a cap; omit to leave it unchanged. ``` When an end-user hits a cap, the run is rejected with a `402`: ```json { "error": { "type": "billing_error", "code": 402, "details": { "error_code": "END_USER_RUN_LIMIT_REACHED" } } } ``` The error code is `END_USER_RUN_LIMIT_REACHED` or `END_USER_COST_LIMIT_REACHED`. These caps limit each end user; the shared prepaid balance must also cover the run. ## Concurrency Concurrent in-flight runs are capped per account, by plan ([Concurrent runs](/docs/limits#concurrent-runs)). When you're at the cap, new runs are rejected with a `429`: ```json { "error": { "type": "rate_limit_error", "code": 429, "details": { "error_code": "SANDBOX_CONCURRENCY_LIMIT" } } } ``` Treat it as back-pressure: wait for in-flight runs to finish, then retry. The SDK already retries `429`s with backoff. ## API key hygiene | Rule | Action | |---|---| | Keep keys on your server | Authenticate end users there and attach `user_id` | | Separate environments | Name keys for production, staging, and CI | | Suspected exposure | Rotate immediately and store the new secret; it is shown once | A named key is a labelled, independently revocable key with an optional expiry. Create one per environment: ```python prod = client.keys.create(name="production") print(prod.id, prod.api_key) # api_key is shown ONCE; store it now client.keys.create(name="ci", expires_in_days=30) # expiry is optional; 1 to 3650 days for k in client.keys.list(): print(k.id, k.name, k.prefix, k.active, k.last_used_at) client.keys.rotate(prod.id) # same id and name, new secret, old secret dies now client.keys.revoke(prod.id) # stops authenticating immediately ``` ```bash curl -X POST https://api.m8tes.ai/api/v2/keys/ \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "production", "expires_in_days": 30}' ``` Handle these key-management errors: | Status | When | What to do | |--------|------|------------| | `429` | You already hold **50 active** named keys (`"API key limit reached (50); revoke an unused key first."`) | Revoke an unused key; that frees a slot immediately | | `403` | The account was created by agent signup and is still **unclaimed** (no password set and not verified) | Claim it via the emailed link before minting or rotating keys | The `403` blocks minting and rotation only. Listing and revoking stay open on an unclaimed account.
Named-key details: expiry, revocation, listing | Field or operation | Behavior | |---|---| | `name` | Required; trimmed; 1–100 characters | | `expires_in_days` | Optional; 1–3650 days | | `active` | False for revoked or expired keys | | `list()` | Newest first; up to 100 rows, including revoked keys | | `last_used_at` | Updated at most once per minute | | `rotate(key_id)` | Keeps id, name, expiry; resets last-used timestamp. Revoked key returns `409` | | `revoke(key_id)` | Idempotent. Another account's id returns `404` | | Key limit | 50 active keys; revoked keys free a slot |
The account's default key Every account also has one legacy default key, managed without an id. It authenticates exactly like a named key and is unaffected by named-key operations: ```python new = client.keys.rotate() # store new.api_key now; it's shown once client.keys.info() # { has_key, prefix }, never the secret client.keys.revoke() # revokes the default key only ``` `client.keys.revoke()` does not touch named keys, and `client.keys.revoke(key_id)` does not touch the default key. Rotating the default key is subject to the same `403` on an unclaimed account.
## Approvals for headless runs Runs default to Auto mode. For unattended (headless) runs there's no human to approve a risky tool call, so either: - Run with `permission_mode="autonomous"` (the agent acts without per-tool approval; appropriate for trusted, well-scoped agents), or - Keep approvals on and handle the pause: a run that needs approval enters `awaiting_approval`; resolve it programmatically with the run's approve / answer endpoints. See [Human-in-the-Loop](/docs/human-in-the-loop) for the full model. ## API stability and versioning `/api/v2` is the supported developer surface. We treat these as **non-breaking**: - Adding optional request fields or response fields - Adding new endpoints or enum values - Deprecating a field or endpoint in docs while keeping it working These are **breaking** and get advance notice when possible: - Removing or renaming a field or endpoint - Changing a field's type or a documented error code's meaning - Tightening validation on an existing field (e.g. shrinking an allowed enum) **Notice:** breaking changes ship with a changelog entry and, when the change affects live integrations, email to the account owner. Deprecated fields stay callable for at least one release cycle before removal. **Machine-readable schema:** `GET https://api.m8tes.ai/api/v2/openapi.json` (V2 routes only; no auth). **SDK:** pin a semver range (`m8tes>=4.49.0,<5`) and read the [Python SDK changelog](https://pypi.org/project/m8tes/) before upgrading. ## Going-live checklist - [ ] Server-side API key only; never exposed to end-users or browsers - [ ] A named key per environment via `client.keys.create(name=...)`, and the one you tested with rotated - [ ] `user_id` set on every run/agent/task that belongs to one of your end-users - [ ] Strict mode on: `client.settings.get().require_end_user_id` is `True` - [ ] `per_end_user_run_limit` and/or `per_end_user_cost_limit_cents` set - [ ] Your code handles `402` (cap reached) and `429` (concurrency / rate limit) gracefully - [ ] Headless runs either use `autonomous` or resolve `awaiting_approval` programmatically - [ ] Balance won't interrupt production: auto-reload or spend alerts set on the prepaid balance (see [Billing & Usage](/docs/billing-usage)) - [ ] Webhook endpoints verify the signature (see [Webhook Events](/docs/webhooks-guide)) - [ ] Channel branding decided: use m8tes branding or arrange your own with [sales@m8tes.ai](mailto:sales@m8tes.ai) Next: [Limits](/docs/limits) · [Data Retention](/docs/data-retention) · [Users](/docs/users) # Limits Source: https://www.m8tes.ai/docs/limits Three kinds of limits: request rates, concurrent runs, and field sizes. All return machine-readable errors. ## Rate limits The API enforces **10,000 requests per minute** per account across all `/api/v2` endpoints, shared by all API keys and dashboard sessions on the account. Authenticated responses include tracking headers: Every figure on this page is the allowance you can rely on, not an exact ceiling: you will never be throttled below it, and in some deployments we allow more. Design against the published number and use the response headers below for the live figure. | Header | Description | |--------|-------------| | `X-RateLimit-Limit` | Maximum requests allowed per minute | | `X-RateLimit-Remaining` | Requests remaining in the current window | | `X-RateLimit-Reset` | Unix timestamp when the limit resets | Past the limit, requests return `429` with a `Retry-After` header (seconds to wait): ```json { "error": { "type": "rate_limit_error", "message": "Rate limit exceeded. Retry after 3 seconds.", "code": 429, "request_id": "req_abc123", "doc_url": "https://www.m8tes.ai/docs/limits#rate-limits" } } ``` The SDKs retry `429` and `5xx` responses with exponential backoff for idempotent methods (`GET`, `PUT`, `DELETE`), and for the run-creating `POST`s, which carry an [idempotency key](/docs/limits#idempotency) so a retry can never start a second run. Any other `POST` or `PATCH` is not retried, to prevent duplicate side effects. A `RateLimitError` only reaches your code once all retries are exhausted. ## Idempotency A run costs money. A timed-out request must not leave you guessing whether you were charged. Send an `Idempotency-Key` header on any run-creating `POST`. A retry returns the run the first attempt made, instead of starting a second one: ```bash curl https://api.m8tes.ai/api/v2/runs \ -H "Authorization: Bearer m8_..." \ -H "Idempotency-Key: order-4242-attempt-1" \ -H "Content-Type: application/json" \ -d '{"message": "Close open tickets", "stream": false, "user_id": "customer_42"}' ``` Supported on `POST /runs`, `POST /runs/first-session`, `POST /runs/with-files`, `POST /runs/{id}/reply`, `POST /runs/{id}/reply/with-files`, and `POST /tasks/{id}/runs`. - **Same key, same request** → `200` with the original run's current state and an `Idempotent-Replay: true` header. Safe to repeat as often as you like. - **Same key, different request** → `409` with `error.details.error_code = "idempotency_key_reuse"`. A key is never silently replayed against a call you did not make. - **Keys last 24 hours** and are scoped to your account, so two accounts can use the same key string without interfering. - **Failed requests do not bind the key.** A `402`, `429`, or `500` leaves the key free. Fix the cause and retry with the same one. The SDKs generate a key for every `runs.create`, `runs.start_first_session`, `runs.reply`, and `tasks.run` automatically, which is what makes their retries safe. Pass your own when a retry must survive a process restart: ```python run = client.runs.create( message="Close open tickets", stream=False, idempotency_key=f"nightly-sweep-{job_id}", user_id="customer_42", ) ``` A replayed streaming call is answered as JSON rather than SSE (the run already exists; there is no fresh stream). The SDKs join the existing run's stream. If that run has already finished, they raise with the run id (`idempotent_replay_terminal`); fetch its result with `runs.get(id)`. ## Concurrent runs Each running run holds its own sandbox, so your plan caps how many run at once. A run waiting on an approval, or paused, counts for a few minutes after it stops, until its sandbox shuts down. | Plan | Runs at once | |------|--------------| | Trial, Hobby | 10 | | Individual | 25 | | Teams (`pro`) | 50 | | Teams (`max_5x`) | 75 | | Teams (`max_20x`) | 100 | | Prepaid API, starter credit | 10 | | Prepaid API, after a paid top-up | 50 | An account on both a plan and a prepaid balance gets the higher number. Read your live count and cap from usage: ```python usage = client.billing.usage() print(usage.concurrent_runs, usage.concurrent_runs_limit) # e.g. 3 50 ``` Each plan's cap is also `max_concurrent_runs` on `GET /api/v2/billing/plans`. Past the cap, new runs return `429` with `error.details.error_code = "SANDBOX_CONCURRENCY_LIMIT"` and a `Retry-After` header. When `details.scope` is `"account"`, `details.limit` is your cap; when `details.scope` is `"platform"` (shared capacity), `limit` is omitted. Wait for a run to finish, then retry. An account-scope 429 also carries `details.upgrade_url` when a bigger plan runs more at once; without it, contact support for more.
Multi-tenant: per-end-user rate limits Rate-limit each end-user's agent turns (run starts and replies) independently, so one end-user bursting can't consume the account: ```python client.settings.update(per_end_user_rate_per_minute=10) ``` Past the rate, run creation and replies for that `user_id` return `429` with `error.details.error_code = "END_USER_RATE_LIMITED"` (plus `end_user_id`, `limit_per_minute`, `retry_after`, and a `Retry-After` header). Other end-users and your own runs are unaffected. Off by default (`null`); send `null` to turn it off again. Related per-end-user run and cost caps: [Users](/docs/users#usage-and-billing).
Advanced: auth endpoint limits (IP-based) Login, registration, and password reset endpoints have a separate limit of **10 requests per minute** per IP address: - `POST /v1/auth/login` - `POST /v1/auth/register` - `POST /v1/auth/forgot-password` - `POST /v1/auth/reset-password` - `POST /v1/auth/verify-email` `POST /api/v2/signup` (agent-driven signup) has its own IP limit of **10 requests per minute**. Unauthenticated `/api/v2` requests (no key, expired/revoked session, or unknown key) are throttled per IP at **120 requests per minute**. Most can only return `401`; fix the credential rather than retrying. Verifying an unrecognised credential is metered by this same allowance (charged before the check, refunded when it turns out to be a real account). A burst of bad keys from one address exhausts it, not ordinary authenticated traffic. A few public endpoints (plan catalogue, agent-template list, `POST /api/v2/token`) are also counted here. A revoked credential keeps counting against its old account for up to ten seconds (rate-limit attribution is cached), then against your IP. It is rejected with `401` either way. These endpoints do not require authentication, so limiting is keyed by IP rather than account, and the response uses a different format: ```json { "detail": "Too many requests. Retry after 30 seconds." } ```
## Field limits Request fields have size and count caps: | Field | Limit | |-------|-------| | `name` | 255 characters | | `instructions` | 50,000 characters | | `metadata` | 10 KB serialized | | `allowed_senders` | 100 items | | `tools` | 50 items | Webhook payloads and inbound events are capped at **256 KB**; larger requests return `413`. The `metadata` field accepts any JSON object under 10 KB serialized. Use it for IDs, labels, and lightweight state, not for large blobs or document content. When a field exceeds its limit, the API returns `422`: ```json { "error": { "type": "validation_error", "message": "metadata must not exceed 10240 bytes serialized", "code": 422 } } ``` Next: [Errors](/docs/api-errors) · [Going Live](/docs/going-live) # Errors Source: https://www.m8tes.ai/docs/api-errors All errors follow a standard format: ```json { "error": { "type": "not_found", "message": "Agent not found", "code": 404, "request_id": "req_abc123", "doc_url": "https://www.m8tes.ai/docs/api-errors#error-types" } } ``` `doc_url` links to the docs page for that error type. Include `request_id` when you contact support. | Field | Use it for | |---|---| | `error.code` | HTTP status, such as `402` | | `error.error_code` | Recovery logic, such as `TOKEN_BALANCE_DEPLETED` | | `error.details` | Recovery URLs, limits, and other context | | `error.message` | Human-readable explanation | Branch on `error_code`, never message text. Older clients can read `error.details.error_code`; Python exposes it as the exception's `.code` or `.error_code`. ## Error Types The four you'll hit while building: | Status | Type | SDK Exception | Description | |--------|------|---------------|-------------| | 401 | authentication_error | AuthenticationError | Invalid or missing API key | | 402 | billing_error | BillingError | Run or cost limit reached, or balance depleted | | 422 | validation_error | ValidationError | Invalid request parameters | | 429 | rate_limit_error | RateLimitError | Too many requests | Catch the exception you can recover from: ```python from m8tes import M8tes, NotFoundError, AuthenticationError, BillingError, RateLimitError client = M8tes() try: agent = client.agents.get(999, user_id="customer_42") except NotFoundError: print("Agent does not exist") except AuthenticationError: print("Check your API key") except BillingError as e: print(e.code, e.details) # machine-readable recovery context except RateLimitError: print("Still rate limited after SDK retries. Try again later.") ``` The SDK retries `429` and `500+` responses with exponential backoff for idempotent methods (GET, PUT, DELETE), and for the run-creating POSTs, which carry an [idempotency key](/docs/limits#idempotency) so a retry returns the original run instead of starting a second one. Any other POST or PATCH is never retried, to prevent duplicate side effects.
All error types and `error.details` conventions | Status | Type | SDK Exception | Description | |--------|------|---------------|-------------| | 400 | invalid_request_error | ValidationError | Invalid request | | 403 | permission_error | PermissionDeniedError | Insufficient permissions | | 404 | not_found | NotFoundError | Resource not found | | 405 | method_not_allowed | APIError | HTTP method not supported on this path | | 409 | conflict_error | ConflictError | Resource conflict (duplicate, not active) | | 500+ | api_error | APIError | Server error (automatically retried for GET/PUT/DELETE) | - Errors that carry a machine-readable app code (e.g. `feature_disabled`, `from_template_conflict`, `RUN_LIMIT_REACHED`) put it in `error.error_code` (mirrored at `error.details.error_code` for older clients), with any recovery context (`missing`, `connect_urls`, `rejected_fields`, …) as sibling keys under `error.details`. The Python SDK surfaces it as the exception's `.code` (and `.error_code` since 4.8.0). - `403 TEAMS_MODEL_LOCKED`: a Teams account set a model other than `deepseek-v4-1-flash` on an agent, task, or run. Omit `model` (or use that one), connect your own provider, or ask about Enterprise for every model. - `400 MODEL_NOT_RUNNABLE`: the chosen model has no connected provider or gateway route that can fund it for this write's scope. Prefer a `runnable: true` row from `GET /api/v2/models` for account-scoped pins (the list is account-scoped); an end-user-scoped write may still succeed when that tenant's prepaid/gateway lane can fund a row that is `false` on the list. Connect the matching provider, or leave Default. - Query params are strict: an unknown query parameter is rejected with `422` (`error.details.error_code = "unknown_query_parameter"`, with a did-you-mean hint). Analytics params (`utm_*`, `gclid`, `fbclid`, `_`) are ignored. Unknown fields in request **bodies** are silently ignored.
## Billing Error Codes For `402`, read `error.error_code` and `error.details` (`BillingError.code` and `.details` in Python): | code | Meaning | Key `details` fields | |------|---------|----------------------| | `RUN_LIMIT_REACHED` | Monthly included runs exhausted | `runs_used`, `runs_limit`, `period_end`, `overage_available` (whether overage can actually be enabled for your account; `false` without an active subscription to bill it to), `free_path_available` (`false` when already on Hobby — model-plan connect is not recovery past the allotment) | | `STARTER_LANE_MODEL_REQUIRED` | Wallet still on the $1 API test credit — only `deepseek-v4-1-flash` is allowed until you top up or connect a provider | `allowed_model`, `requested_model`, `topup_url`, `connect_model_url` | | `TOKEN_BALANCE_DEPLETED` | Prepaid token balance exhausted (API/developer billing). Top up, or [connect a model subscription](/docs/billing-usage#develop-on-your-model-subscription) for personal development | `balance_micros`, `balance_usd`, `topup_url`, `connect_model_url` (legacy: `connect_claude_url`) | ```python from m8tes import BillingError try: run = client.runs.create_and_wait(agent_id=mate.id, message="...", user_id="customer_42") except BillingError as e: if e.code == "RUN_LIMIT_REACHED" and e.details.get("overage_available"): client.billing.set_overage(enabled=True, monthly_cap_cents=5000) # keep running elif e.code == "TOKEN_BALANCE_DEPLETED": checkout_url = client.billing.topup(amount_cents=5000) # add $50 of credit print("Top up to continue:", checkout_url) else: print(e.code, e.details) ```
All billing codes, plus the two 429 guards | code | Meaning | Key `details` fields | |------|---------|----------------------| | `OVERAGE_CAP_REACHED` | Usage overage spend cap hit | `overage_used_cents`, `overage_cap_cents`, `period_end`, `overage_settings_url` (raise the cap or upgrade) | | `TRIAL_EXPIRED` | Time-boxed trial has ended | `trial_ends_at`, `upgrade_url` | | `COST_LIMIT_REACHED` | Fair-usage cost backstop hit | `runs_used`, `runs_limit`, `period_end` | | `SUBSCRIPTION_BLOCKED` | Payment issue (past_due / unpaid) | `subscription_status` | | `FUNDING_REQUIRED` | No usable model provider or paid plan. When `free_path_available` is `true`, connect a model provider to continue for free; otherwise recovery is paid-only. | `plan`, `free_path_available`, `connect_model_url`, `upgrade_url`, `demo_url`, `demo_grants_access` | | `END_USER_RUN_LIMIT_REACHED` | One end-user hit your per-end-user run sub-cap (other end-users unaffected) | `end_user_id`, `runs_used`, `runs_limit`, `period_end` | | `END_USER_COST_LIMIT_REACHED` | One end-user hit your per-end-user cost sub-cap | `end_user_id`, `cost_used_cents`, `cost_limit_cents`, `period_end` | Two more guards return `429 rate_limit_error` (not 402): - `END_USER_RATE_LIMITED`: an end-user burst past your `per_end_user_rate_per_minute` setting. `details` carry `end_user_id`, `limit_per_minute`, `retry_after`, plus a `Retry-After` header. Configure all three sub-caps via `client.settings.update(...)`; see [Users](/docs/users#usage-and-billing). - `SANDBOX_CONCURRENCY_LIMIT`: too many runs in flight at once. `details.scope` says which ceiling: `account` (your own burst; `details` carry your `concurrent_runs` and `limit`, plus `upgrade_url` when a bigger plan runs more at once) or `platform` (overall capacity, no counts). Wait for in-flight runs to finish and retry after the `Retry-After` header. The cap depends on your plan ([Concurrent runs](/docs/limits#concurrent-runs)); upgrade or contact support for more.
## Run-Level Failures The exceptions above cover problems *reaching* the API. A run can also fail *upstream*: an expired Claude credential, an exhausted quota, a model rate limit. The HTTP request succeeds (no exception), but the run ends with `status` `"failed"`, the message in `run.output`, and a machine-readable class in `run.error_code`. Always check it before trusting `output`: ```python run = client.runs.create_and_wait(agent_id=mate.id, message="...", user_id="customer_42") if run.error_code: print(f"run failed upstream: {run.error_code}: {run.output}") else: print(run.output) ```
Run `error_code` reference Transient codes are retry-safe; credential codes need re-auth first. | code | Meaning | Run status | Retry safe? | |------|---------|------------|-------------| | `oauth_revoked` | The connected model-plan credential was revoked or expired | failed | After reconnecting OAuth | | `subscription_quota_exhausted` | The connected Claude, OpenAI/Codex, xAI/Grok, or Gemini plan hit its usage quota | failed | After the provider quota window resets | | `model_unavailable` | The requested model is not available on the connected provider plan | failed | After switching to a matching model or connecting that provider | | `model_pin_removed` | The pinned model is no longer in the catalog | failed | No. Pick a current model, or clear the pin | | `api_credit_balance` | The upstream provider key ran out of credit | failed | After the balance is restored | | `rate_limited` | Upstream model rate limit hit mid-run (auto-Continue on own-sub + scheduled) | failed | Yes | | `overloaded` | The upstream provider was overloaded or down, or its connection dropped before the reply finished (5xx/overload/dropped stream; auto-Continue on own-sub + scheduled) | failed | Yes | | `tool_limit_exceeded` | The model provider rejected the request because it contained more tools than the provider allows | failed | After reducing connected tools or changing the integration set | | `safety_refusal` | The model provider's safeguards flagged the request and the model declined to answer (for Claude, a usage-policy refusal). This can happen with ordinary requests | failed | After rephrasing the request or switching to a different model. Never retried automatically | | `provider_client_outdated` | The model provider refused the request because the client the platform used to reach it is older than the provider accepts (HTTP 426). A platform-side problem, not the account's | failed | No. The same client version gets the same answer; we update it (reported automatically). Never retried automatically | | `sandbox_boot_timeout` | The sandbox never started; nothing executed | failed | Yes (auto-retried on scheduled runs) | | `sandbox_quota_exhausted` | The execution platform was at capacity, so no sandbox could be created; nothing executed | failed | Not immediately. The same limit will refuse a retry; wait, then retry | | `incomplete_stream` | The stream died before finishing; the agent may have partially executed | failed | Review output first | | `snapshot_version_mismatch` | The sandbox image speaks a different agent-runtime contract than the platform. Cold starts older than the one-pin compat window usually auto-heal onto a fresh image; a second mismatch, a rollback (newer bake than the platform), or a mid-session incompatible sandbox fails closed | failed | Only when `auto_retryable` is true (heal pending). Otherwise no — retrying the same image cannot help; we update the pin (reported automatically) | | `internal_timeout` | An internal service timed out while the run was starting or executing | failed | Yes | | `service_unreachable` | A service the run depends on was unreachable | failed | Yes | | `sandbox_unavailable` | The sandbox could not be provisioned, or was lost mid-run | failed | Yes (auto-retried on scheduled runs when nothing had executed yet) | | `storage_error` | A temporary storage error interrupted the run | failed | Yes | | `deploy_interrupted` | A platform deploy interrupted the active process; task runs (including scheduled runs) with a saved session resume in place | failed, then running if recovery claims it | Automatic for eligible task runs; otherwise retry | | `orphaned_on_restart` | The run was interrupted before it finished — by a restart, or by a worker that was killed; found at startup, or within minutes by the ownership lease | failed, then running if recovery claims it | Automatic for eligible task runs; otherwise retry | | `stuck_running_no_activity` | The run stopped producing activity and its process could not recover it | failed | Yes; review partial output first | | `subagent_stopped_at_completion` | Delegated work was still running when the parent turn ended | failed | Resumes automatically: silently in the run viewer, and server-side for scheduled and triggered runs; no action needed | | `error_max_turns` | The run used every conversation turn its `max_turns` budget allowed. A new message or Continue re-arms the budget on that run; a retry starts a child without the parent's `max_turns`. The Platform run viewer auto-Continues once | failed | Continue / reply (preferred); retry creates a child with a fresh/default budget. Raise `max_turns` if the work genuinely needs more in one request | | `error_max_budget_usd` | The run reached the spend budget set for the request | failed | Continue / reply starts a fresh turn (budget is unchanged); reduce what the work asks for in one turn, or start a new run with a higher budget | | `error_max_structured_output_retries` | The run's final answer did not match the requested `output_schema` within the allowed attempts; earlier work in the run may already be done | failed | After checking the schema: a new message, or a retry | | `context_compact_thrash` | The SDK aborted after compacting three times in a row because the conversation refilled to the context limit. A new message or Continue starts a fresh turn; retrying the same turn hits the same wall | failed | Continue / reply; do not retry the same turn | | `prompt_too_long` | The conversation exceeded the model's context window, including when automatic compaction failed. Earlier work and messages remain on the original run | failed | Not automatic. Continue resumes the same conversation and can hit the same limit. A task retry starts fresh; a chat retry replays the failed turn in the same conversation and can hit the limit again. Review partial work before retrying; a retry can repeat actions | | `internal_error` | Unclassified platform error; reported automatically on our side | failed | Yes, then contact support | | `unknown` | Unclassified upstream error; the message is in `run.output` | varies | Review output first | Interrupted task runs continue their saved session, preserving the run ID and conversation. A non-null `next_retry_at` means automatic recovery is queued; keep polling the run or rejoin its stream when its status returns to `running`. Recovery checks every minute starting three minutes after server startup, waits for any platform update to finish, and uses the shared three-attempt limit. A newer run of the task, cancellation, or unavailable funding can stop recovery; `next_retry_at` then clears and manual recovery is available. Guarded coding runs cannot resume their discarded sandbox session and require manual recovery. Scheduled failures without a saved session retain their existing fresh retry policy, which refuses to replay work after external writes. Cancel pending recovery with `POST /api/v2/runs/{run_id}/cancel` or `client.runs.cancel(run_id)`. A queued failed run becomes `cancelled` and its `next_retry_at` clears, preventing either recovery worker from starting it. For `oauth_revoked`, `subscription_quota_exhausted`, `model_unavailable`, and an own-subscription `rate_limited` failure, manual recovery is the exception to model pinning: retrying a task or replying to a chat resolves the account's current connected/preferred provider instead of replaying the inaccessible concrete model. Connect or select another provider first to switch; a chat reply resends only that failed follow-up on the same run. Other failure retries keep the original model. | Block before execution | Recorded result | |---|---| | Billing or account gate | Failed run with the gate's code; nothing executed | | Gate has no code | `ADMISSION_BLOCKED` | | Recurring schedule: `FUNDING_REQUIRED`, `MODEL_CONNECTION_REQUIRED`, or `CLAUDE_CONNECTION_REQUIRED` | Skip without a failed run; try the next occurrence | | Three consecutive skips above | Schedule pauses (`auto_disabled`); one email sent; re-arms when the gate clears | Account gates include `EMAIL_VERIFICATION_REQUIRED`, `ACCOUNT_SUSPENDED`, `ACCOUNT_DELETED`, and `API_WALLET_REQUIRED`. Restart a blocked one-off run after resolving the gate.
Next: [Limits](/docs/limits) · [Going Live](/docs/going-live) # Pagination Source: https://www.m8tes.ai/docs/api-pagination Paginated resource lists return this envelope: ```json { "data": [...], "has_more": true } ``` ## Parameters Use these parameters on the [endpoints below](#supported-endpoints): | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results per page (1–100, default 20) | | starting_after | int | Cursor: ID of the last item from the previous page | ## Fetching the Next Page Pass the last item’s ID as `starting_after` to fetch the next page. When `has_more` is `false`, there are no more results. ```python page = client.agents.list(limit=10, user_id="customer_123") for agent in page.data: print(agent.name) # Fetch next page if page.has_more: next_page = client.agents.list(limit=10, user_id="customer_123", starting_after=page.data[-1].id) ``` ```bash # First page curl "https://api.m8tes.ai/api/v2/agents?user_id=customer_123&limit=10" \ -H "Authorization: Bearer m8_your_key_here" # Next page (using last item's ID) curl "https://api.m8tes.ai/api/v2/agents?user_id=customer_123&limit=10&starting_after=15" \ -H "Authorization: Bearer m8_your_key_here" ``` ## Supported Endpoints These resource lists support cursor pagination: - `GET /agents` - `GET /runs` - `GET /tasks` - `GET /webhooks` - `GET /memories` - `GET /permissions` - `GET /users` # Webhook events Source: https://www.m8tes.ai/docs/webhooks-guide > **These are outbound webhook events:** your server gets notified when runs complete. For **inbound** webhooks that start runs, see [Webhook Triggers](/docs/webhook-triggers). Register endpoints for signed HTTP POSTs when runs complete, fail, or need input. Failed deliveries retry automatically. ## Register a Webhook ```python from m8tes import M8tes client = M8tes() webhook = client.webhooks.create( url="https://your-app.com/webhooks/m8tes", events=["run.completed", "run.failed", "run.cancelled"], user_id="customer_123", # user_id="customer_123", # optional: only that end-user's runs deliver here ) # Save the secret; it is only returned on creation print(webhook.secret) # "a1b2c3d4e5f6..." ``` > **End-user scope:** omit `user_id` for account-level endpoints (only account-level runs deliver). Pass `user_id` so only that end-user's runs deliver — scopes never mix, and there is no fallback to the account URL. > **Important:** The `secret` is only shown once, on creation. Store it securely for signature verification. ## Available Events | Event | Description | Default | |-------|-------------|---------| | `run.started` | A run began execution (fires on every attempt, including replies/resumes) | No | | `run.completed` | A run finished successfully | Yes | | `run.failed` | A run encountered an error | Yes | | `run.cancelled` | A run was cancelled | Yes | | `run.awaiting_input` | A run is waiting for user input or approval | No | | `run.message_received` | A message was queued for a run (an agent's send, or a reply that arrived mid-turn) | No | | `run.message_cancelled` | A queued message became undeliverable (cancelled/expired run, disabled account, 24h staleness) | No | | `connection.expired` | A connected app lost authorization (token expired or revoked) | No | | `balance.low` | Prepaid token balance fell to/below your warning threshold (prepaid accounts) | No | | `balance.critical` | Prepaid balance is almost gone (20% of the threshold); runs will fail soon | No | | `balance.depleted` | Prepaid balance hit $0; runs are paused until you add credit | No | The `balance.*` events let you react before runs start failing (auto-top-up, page on-call). Each carries `balance_micros`, `balance_usd`, and `currency`; set the threshold with `client.billing.set_alert_threshold(low_balance_threshold_cents=...)`. ## Payload Format ```json { "id": "evt_abc123", "type": "run.completed", "created_at": "2026-01-15T10:05:00Z", "data": { "id": 42, "teammate_id": 1, "status": "completed", "output": "Done! Closed 5 tickets.", "needs_reply": false, "needs_reply_count": null, "headline": "closed all open tickets", "delivery_channel": "email", "error": null, "error_code": null, "user_id": null } } ``` > `teammate_id` is the wire name for the agent id: JSON request and response bodies keep it. The SDK accepts `agent_id` (canonical) and `teammate_id` alike. `output` is the agent's closing message, clean prose. On `run.completed`, envelope fields ride along: - `delivery_channel`: how the agent chose to reach the owner (`email`, `slack`, or `none`) via `set_run_delivery`. When `slack` or `none`, the platform suppresses the completion email (first-party scheduled tasks only). - `needs_reply`: `true` when the agent needs a decision (`needs_reply_count` is how many). Surface the output, collect a reply, continue with a follow-up message. - `headline`: one-line outcome summary (may be `null`), capped at 200 characters. Escape it like any untrusted string; never interpolate into HTML or commands unescaped. On `run.failed`, `error` says why in plain language and `error_code` is the machine-readable code (the same values as `error_code` on the run). Branch on `error_code`, show `error`. Treat `run.failed` as the latest state, not a permanent one. When the failure was a transient infrastructure error, the platform may automatically retry or resume the same run, so a `run.failed` can be followed by `run.started` and a terminal `run.completed` for the same run id. Key your handler on the most recent event. For `run.awaiting_input`, `data.status` is `awaiting_approval`. When the pause is a question (AskUserQuestion), `data.questions` carries the full question list, so no separate `runs.permissions()` call is needed. See the payload example in [Human-in-the-Loop](/docs/human-in-the-loop). Tool-approval pauses omit `questions`. For `run.message_received`: - `data.id` is the TARGET run; `data.message_id` is the queued message. - `data.sender_kind` is `"api"` (reply while mid-turn), `"agent"` (another agent's run; `data.sender_run_id` and `data.sender_teammate_id` are set), or `"platform"`. The message delivers as the run's next turn when the current one ends; `run.started` fires for that turn. Every queued message resolves to exactly one of: a delivery turn (`run.started`), or `run.message_cancelled` with a `reason` (owner cancelled [agent senders only; your own replies still deliver], sandbox expired, account disabled, or undelivered for 24 hours).
Cancellation payload (run.cancelled) Cancellations fire their own `run.cancelled` event. Until mid-2026 they arrived as `run.failed` with `data.status: "cancelled"`; webhooks created before then should add `run.cancelled` to their subscription. ```json { "id": "evt_def456", "type": "run.cancelled", "created_at": "2026-01-15T10:06:00Z", "data": { "id": 43, "teammate_id": 1, "status": "cancelled", "output": null, "error": null, "user_id": null } } ```
## Handling Connection Expiry When a connected app's token expires or is revoked, a `connection.expired` event fires. Use it to notify the affected user and trigger a reconnect flow. App triggers that use the expired connection are disabled automatically; reconnecting re-enables the flow. See [Webhook Triggers](/docs/webhook-triggers).
Payload and handling code: re-initiate OAuth on expiry ```json { "id": "evt_abc123", "type": "connection.expired", "created_at": "2026-01-15T10:05:00Z", "data": { "integration_id": 42, "provider": "gmail", "kind": "email", "account_id": "conn_abc123" } } ``` ```python # re-initiate oauth for the affected end-user if event["type"] == "connection.expired": provider = event["data"]["provider"] conn = client.apps.connect_oauth( provider, redirect_uri="https://yourapp.com/callback", # the payload has no user_id; it carries integration_id/provider/kind/account_id. # resolve the affected end-user from your own mapping if you scope per user ) # redirect user to conn.authorization_url ```
## Signature Verification Verification needs no client and no API key. Pass the **raw** request body: a parsed-then-restringified object will not match, because re-serializing does not preserve key order or spacing. Each request includes `Webhook-Id`, `Webhook-Timestamp`, and `Webhook-Signature` headers, where the signature is `v1=HMAC-SHA256(secret, "{id}.{timestamp}.{body}")`. Use the SDK helper to verify: ```python from m8tes import Webhooks is_valid = Webhooks.verify_signature( body=request.body, headers=request.headers, secret=your_webhook_secret, tolerance_seconds=300, # reject payloads older than 5 minutes ) ``` ```ts import { verifySignature } from "@m8tes/sdk"; export async function POST(req: Request) { const raw = await req.text(); const valid = verifySignature(raw, req.headers, process.env.M8TES_WEBHOOK_SECRET!, { toleranceSeconds: 300, // reject payloads older than 5 minutes }); if (!valid) return new Response("bad signature", { status: 401 }); const event = JSON.parse(raw); console.log(event.type, event.data.id); return new Response("ok"); } ``` ## Manage Webhooks ```python page = client.webhooks.list(user_id="customer_123") # secrets are masked client.webhooks.update(webhook.id, events=["run.started", "run.completed"], user_id="customer_123") client.webhooks.update(webhook.id, active=False, user_id="customer_123") # pause deliveries client.webhooks.delete(webhook.id, user_id="customer_123") for d in client.webhooks.list_deliveries(webhook.id, user_id="customer_123").data: # delivery history print(d.event_type, d.status, d.attempts) ``` ## Retry Behavior Delivery is attempted up to 3 times with exponential backoff (2s, 4s delay); any 2xx counts as success. After all attempts the delivery is marked failed, visible via `list_deliveries()`. Pending deliveries are also retried if the server restarts. Next: [Webhook Triggers](/docs/webhook-triggers) · [Streaming](/docs/streaming) · [Scheduling](/docs/scheduling) # Data retention Source: https://www.m8tes.ai/docs/data-retention Choose how much run content m8tes stores. `metadata_only` removes stored outputs and transcripts; **starting instructions and configuration are still retained**. Provider retention is a separate setting. ## Two modes | `retention_mode` | What we store | |------------------|---------------| | `standard` (default) | Full conversation content, tool inputs/outputs, and generated reports. | | `metadata_only` | Run metadata and configuration, including starting instructions. No stored outputs or transcripts. | ```python from m8tes import M8tes client = M8tes() client.settings.update(retention_mode="metadata_only") # reduce stored run content ``` The mode is resolved onto each run when it starts, so it applies to every run created after you set it. ## What `metadata_only` does and doesn't keep | Not stored after the run | Retained | |---|---| | Replies, transcript, reasoning, run output | Starting instruction | | Tool inputs and outputs | Tool names, status, timing, audit events | | Report bodies and summaries | Document names | | New memories, lessons, learned company context (writes refused) | Agent/task definitions and schedules | | Conversation content | Roles, ordering, token counts, cost | Paused tool-call arguments are cleared when the run ends. Sensitive text in a starting instruction or configuration field remains stored. Contact support if you need fully ephemeral inputs. ## What stops working under `metadata_only` | Feature | Result | |---|---| | Past transcripts, summaries, replay | No content to display | | Saved reports | Name only; no body or summary to search | | New cross-run memories | Writes refused | | History tools | Metadata only | | Live execution, billing, audit trail | Still available | Retention is account-wide and applies to new runs. It does not erase earlier content. Per-agent overrides are not available. ## About upstream model providers `retention_mode` controls m8tes storage. Model-provider retention depends on the route: | Route | What to check | |---|---| | Gateway with a ZDR-capable host | ZDR must be enabled on the key; the routed host must support it | | Model with no confirmed ZDR host | It may retain data. Check `zdr_supported` in the model catalog | | Your connected model provider | The provider's subscription terms apply | Gateway ZDR is enabled by default. A model's `zdr_supported=true` means a capable host exists; it does not guarantee every route uses that host. See [Models](/docs/models#zero-data-retention-zdr). ## Choosing a mode Use `standard` for history, reports, and cross-run memory. Use `metadata_only` to minimize stored run content. For sensitive data, review both the retained configuration above and your model-provider route. ## Export everything the account holds `client.account.export()` returns the account's stored data as one JSON document. Use it to answer a right-of-access request, or to snapshot state before deleting. ```python data = client.account.export() print(data.keys()) # dict_keys(['account', 'teammates', 'tasks', 'runs', 'documents', 'memories', 'integrations']) ``` ```bash curl https://api.m8tes.ai/api/v2/account/export \ -H "Authorization: Bearer m8_your_key_here" ``` Two things are deliberately left out. **Secrets are never exported**: password and API-key hashes, encrypted integration tokens and the API keys inside integration metadata, and MFA secrets. **Run transcripts are not exported either**, because a message can contain a credential the agent read at runtime. The `runs` section carries status, trigger source, channel, and timing only.
Fields in each section | Section | Fields | |---------|--------| | `account` | `id`, `email`, `first_name`, `last_name`, `phone`, `company`, `job_title`, `country`, `plan`, `created_at` | | `teammates` | `id`, `name`, `role`, `instructions`, `goals`, `status`, `created_at` | | `tasks` | `id`, `agent_instance_id`, `name`, `instructions`, `status`, `created_at` | | `runs` | `id`, `status`, `trigger_source`, `channel`, `created_at` | | `documents` | `id`, `name`, `summary`, `content`, `scope`, `created_at` | | `memories` | `id`, `content`, `created_at` | | `integrations` | `id`, `provider`, `kind`, `status`, `account_id`, `created_at` | The export is account-wide. It is not scoped by `user_id`, so it includes every end-user's records.
## Delete the account `client.account.delete()` requests erasure. It returns `202` and the grace window: ```python client.account.delete() # {'status': 'deletion_requested', 'grace_period_days': 30} ``` ```bash curl -X DELETE https://api.m8tes.ai/api/v2/account \ -H "Authorization: Bearer m8_your_key_here" ``` **Export first.** Deletion immediately revokes sessions, API keys, and webhook credentials and prevents login. | When | What happens | |---|---| | Immediately | Access revoked; cancellation of billing, schedules, and active runs is attempted | | After the 30-day grace window | A daily job erases account data | | During the grace window | Contact support to undo the request; no restore endpoint exists |
Exactly what is erased and what is retained **Erased at the end of the grace window:** agents, tasks, schedules, runs and their conversation messages, documents, memories, integrations and their OAuth tokens, webhook endpoints, custom MCP servers, Slack installs, Apple Messages bridges, inbound event receipts, end-user records, tool permission policies, MFA secrets and recovery codes, and refresh tokens. An organization where you were the only member is deleted with it; one with other members survives, with its name replaced by "Workspace". **Retained, anonymized:** the account row is scrubbed in place rather than deleted, because retained financial and audit records point at it. `email` becomes `deleted+@deleted.invalid`, and name, phone, company, job title, country, and password are cleared. Audit logs keep the aggregate access trail with the identifiers nulled (IP address, API-key prefix, request id, `end_user_id`, and `resource_id`). Stripe customer and subscription ids are kept so retained invoices can be reconciled; Stripe holds its own copy of the billing record.
Next: [Going Live](/docs/going-live) · [Users](/docs/users) · [Limits](/docs/limits) # Billing & usage Source: https://www.m8tes.ai/docs/billing-usage Every account has two billing meters. Each run settles on exactly one: | Meter | Covers | Billed as | |-------|--------|-----------| | **Prepaid balance** | Runs with `user_id` (work for one of your end users) | Per token, prepaid wallet, provider list prices (no markup) | | **Platform plan** | Your own runs: web app, and API/SDK calls with no `user_id` | Included runs per month by plan, plus optional per-run overage | The presence of `user_id` decides the meter, even for API calls. Plan allowances and prepaid funds are separate; neither pays for the other. New API accounts require `user_id` by default. **API signups get $1 promotional prepaid credit** spendable on `deepseek-v4-1-flash` until you [top up](#prepaid-balance) (full catalog) or [connect a model subscription](#develop-on-your-model-subscription) for BYO personal development. Web/platform signups start the wallet at $0. ## Plan usage ```python usage = client.billing.usage() # client.auth.get_usage() still works print( usage.plan, usage.runs_used, usage.runs_limit, usage.period_end, usage.free_path_available, ) print(usage.cost_used, usage.overage_enabled, usage.overage_used_cents) ``` ```bash curl https://api.m8tes.ai/api/v2/usage \ -H "Authorization: Bearer m8_your_key_here" ``` > **What counts as a billable run?** One execution that completes with output: manual, scheduled, webhook, email, reply, or retry. On the plan meter, failed runs with no output do not count against included runs. | `usage.plan` | Plan | Model funding | |---|---|---| | `inactive` | Setup incomplete | Connect a model provider or choose a paid plan | | `trial` | Time-limited trial | Check account eligibility | | `free` | Hobby | Your connected model provider | | `individual` | Individual | Your connected model provider | | `pro`, `max_5x`, `max_20x` | Teams: 500, 2,000, or 5,000 tasks | Platform inference on GPT-6.1 Sol (Enterprise: all models) | List plans with `client.billing.plans()`; add `include_free=True` for Hobby. Use `usage.free_path_available` to decide whether to offer Hobby recovery. False or missing means offer a paid plan. Connecting a model subscription does not bypass Hobby's run allowance. ## Usage overage Opt in to keep running past your plan's included runs, billed per run up to a monthly cap you set: ```python client.billing.set_overage(enabled=True, monthly_cap_cents=5000) # $50 cap ```
Details: how overage kicks in - When the included allotment is exhausted, a run returns `402 RUN_LIMIT_REACHED` with `overage_available` in `error.details` (`true` only when overage can be enabled for your account). On an account already on Hobby, `free_path_available` is `false` — connecting a model provider is not recovery past the allotment. - Enabling overage needs an active platform-inference subscription. It is unavailable on trial, Hobby (stable slug `free`), and Individual because their inference is customer-supplied; the call returns `402 OVERAGE_UNAVAILABLE`. - See [errors](/docs/api-errors#billing-error-codes).
## Prepaid balance Each run with `user_id` debits a micro-USD balance at provider list prices. Plan run limits and overage do not apply to this meter. A failed or cancelled run that consumed tokens is still debited. A retry is a separate run and a separate debit. `topup()` starts Stripe Checkout and returns the checkout URL. Credits expire on a rolling window: purchased credits 12 months after your most recent paid top-up (any paid top-up refreshes the whole balance); promotional credits expire 90 days after they are granted unless you make a paid top-up. You get an email before anything expires. Effective September 8, 2026, except where prohibited by applicable law. See the [Terms](/terms). ```python url = client.billing.topup(amount_cents=2000) # $20, opens Stripe Checkout bal = client.billing.balance() print(bal.balance_usd, bal.balance_micros) # balance_usd is display-only ``` ```bash curl https://api.m8tes.ai/api/v2/billing/balance \ -H "Authorization: Bearer m8_your_key_here" ``` When the balance is depleted, a run returns `402 TOKEN_BALANCE_DEPLETED`. The SDK raises `BillingError` with `.details` carrying `balance_micros` and a `topup_url`. Recover by topping up, or [connect a model subscription](#develop-on-your-model-subscription) for personal development. See [errors](/docs/api-errors#billing-error-codes).
Details: top-up mechanics and signup notes - Top-ups are $5 minimum per charge and available to every account, including plan accounts covering API usage. - The balance is credited asynchronously once payment completes (after the `checkout.session.completed` webhook), so `balance()` won't reflect it until then. - Any completed top-up Checkout saves your card, which enables [auto reload](#auto-reload). - `balance()` also returns the recent transaction ledger: top-ups, reloads, per-run debits, and adjustments with `balance_after_micros` for each. - Signup dedupes alias-collapsed emails: plus-tags (`you+anything@…`) always map to the base mailbox, and Gmail dot variants collapse too. An address that aliases an existing account is rejected as already registered.
## Develop on your model subscription Connect an external model provider in [Account → Model providers](/account). Finish the provider sign-in and wait for **Connected**. See the [complete setup steps](/docs/quickstart#3-run-your-first-agent). On eligible trial/inactive accounts, connecting activates Hobby. Personal runs use your connected external model provider and your usage limits, and count against the platform plan's allowance. The prepaid balance is reserved for runs with `user_id`. Use a matching model and disable strict mode for personal development: ```python # Personal development only: this disables strict user_id checks account-wide. # This persists. For customer-facing apps, keep strict mode on and pass user_id. client.settings.update(require_end_user_id=False) run = client.runs.create_and_wait(message="say hi", model="grok-4.6") # no user_id print(run.auth_method, run.auth_provider) # "oauth_subscription", e.g. "xai" ```
Details: scope and caveats - **Best for personal development.** Consumer subscriptions carry each provider's own usage windows, rate limits, and terms. Production and multi-tenant traffic should stay on the prepaid balance. - The credential is account-level and is never attached to a run created with `user_id`. Per-end-user provider credentials are not supported. - The model must match the connected provider: Claude for Anthropic, GPT/Codex for OpenAI, Grok for xAI, and Gemini for Google. Other models use the normal route or fall back according to the account's model settings. - **Several accounts per provider.** Connect a second Claude or ChatGPT account by running the same sign-in again with the other account. Each appears in `connection.accounts`. Runs use an account that has not just hit its plan limit. When one does, the run retries once on a sibling account before switching provider. Remove one with `client.model_connections.disconnect_account(provider, account_id)`. - `run.auth_method` and `run.auth_provider` identify the inference route. Use `settled_meter="wallet"` in usage reports to find prepaid debits. - If an own-subscription turn fails because of provider auth, quota, rate limit, or model availability, connect or select another provider and recover the run. Task retry and chat reply resolve the current connected/preferred provider instead of reusing the failed concrete-model pin; chat resends only its failed follow-up.
## Auto reload When the balance falls below your threshold, m8tes charges your saved card the reload amount and credits it automatically: ```python client.billing.set_auto_reload( enabled=True, threshold_cents=500, # reload when balance < $5 amount_cents=2000, # charge $20 per reload ) ```
Details: card requirement and reload cadence - Any completed top-up Checkout saves your card; enabling auto reload without one returns `402 NO_SAVED_PAYMENT_METHOD`. An account that has never topped up returns `403 NO_PREPAID_BALANCE`. - Reload amounts run from $5 to $10,000 per charge (lower than the manual top-up ceiling; these charges happen off-session). - At most one reload fires per 6-hour window, so a declined card is never retried on every run, and Stripe emails a receipt for each charge. - Reloads appear in `balance()` transactions and in `receipts()` alongside manual top-ups.
## Spend alerts Three escalating alerts fire as the balance drains (low, critical, depleted). Each sends an email and a [webhook event](/docs/webhooks-guide) so your systems can react, for example by triggering your own top-up flow. ```python client.billing.set_alert_threshold(low_balance_threshold_cents=2000) # warn below $20 ```
Details: alert tiers | Tier | Fires when | Webhook event | |------|------------|---------------| | Low | balance ≤ your threshold (default $5) | `balance.low` | | Critical | balance ≤ 20% of the threshold | `balance.critical` | | Depleted | balance ≤ $0, runs fail until you top up | `balance.depleted` | Each tier fires at most once per depletion cycle; all three re-arm on the next top-up.
## Usage over time and receipts ```python series = client.billing.usage_timeseries(group_by="model") for b in series.buckets: for s in b.models: print(b.date, s.model, s.cost_usd) page = client.billing.receipts() # paid top-ups with Stripe-hosted receipt links for r in page.data: if r.has_invoice: print(client.billing.invoice(r.id).invoice_pdf_url) # fresh invoice PDF link ``` Every top-up made through Checkout comes with a Stripe invoice PDF. Add a business tax ID and billing address at checkout and they print on it.
Details: timeseries, invoices, and per-run metrics - `usage_timeseries()` returns daily token + USD buckets, zero-filled over the window (default: last 30 UTC days), reconciling with `usage().cost_used`. - Filter with `user_id` (one end-user) or `agent_id`; pass `group_by="model"` for per-model slices in each bucket. - `surface="api"` / `"platform"` filters the creation stamp. `settled_meter="wallet"` filters runs that actually hit the prepaid ledger. The two differ when an api-stamped run settles on the plan. - `teammate_id` is the wire name for the agent id: query params and JSON bodies keep it. The SDK accepts `agent_id` (canonical) and `teammate_id` alike. - Invoice links expire, so `invoice()` fetches a fresh one each call. Call it right before you download, and don't store the URL. - Auto-reloads and top-ups from before invoices shipped have `has_invoice=False`; use their `receipt_url` instead. - Every run response also carries per-run metrics: `run.usage` has the token lanes (input/output/cache) and `cost_usd`, the exact figure billing meters. It's `None` until the run produces metrics.
## Per-end-user caps and usage Multi-tenant accounts can cap each end-user's runs, cost, and rate, and break the billing period down per end-user with `client.users.usage()`. See [Users](/docs/users#usage-and-billing). Next: [Users](/docs/users) · [Limits](/docs/limits) · [Errors](/docs/api-errors) # Introduction Source: https://www.m8tes.ai/docs/api-introduction Create agents, give them tasks, and stream their results through a REST API. Set [`user_id`](/docs/users) to scope memory, tools, and history to one end user. ![An agent holds instructions and tools. A task defines the work. A run executes it and returns output and usage.](/docs/diagrams/agent-task-run.svg) Base URL: `https://api.m8tes.ai/api/v2` The SDK targets this URL by default; override it with the `M8TES_BASE_URL` env var or `M8tes(base_url=...)`. A custom base URL must include the `/api/v2` prefix (e.g. `http://localhost:8000/api/v2` for self-hosted); without it every call 404s. ## Authentication All requests require an API key in the `Authorization` header. API keys use the `m8_` prefix. Get yours from the [Developer dashboard](https://www.m8tes.ai/developer). ```python from m8tes import M8tes client = M8tes(api_key="m8_your_key_here") # or set M8TES_API_KEY env var and call M8tes() with no args ``` ```bash curl https://api.m8tes.ai/api/v2/agents?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` Create or rotate a key with `POST /api/v2/token`. Rotation invalidates the previous key, so update your stored secret immediately. Named keys, rotation, env-var conventions, and sessions vs. keys: [Authentication](/docs/authentication). Key hygiene rules: [Going Live](/docs/going-live#api-key-hygiene). ## Resources | Resource | Description | |----------|-------------| | [Agents](/docs/agents) | Instructions, tools, and triggers | | [Runs](/docs/runs) | Task executions that stream Server-Sent Events | | [Audit Logs](/docs/api-reference) | Account-scoped API request history and debugging | | [Tasks](/docs/tasks) | Reusable job definitions with triggers (schedule, webhook, email) | | [Apps](/docs/tools) | Available tools and OAuth integrations | | [Memories](/docs/memories) | End-user memory management | | [Permissions](/docs/human-in-the-loop) | Tool allow-lists for end-users | | [Users](/docs/users) | End-user profiles for multi-tenant apps | | [Settings](/docs/api-reference) | Account-level feature configuration | | [Usage](/docs/billing-usage) | Billing usage, run counts, and account limits | | [Webhooks](/docs/webhooks-guide) | Event notification subscriptions | ## Usage Inspect your plan, usage, and limits with `GET /api/v2/usage` or `client.billing.usage()` (`client.auth.get_usage()` still works). Plans, the prepaid balance, top-ups, and usage breakdowns: [Billing & Usage](/docs/billing-usage). ## Conventions Resource lists use [cursor-based pagination](/docs/api-pagination); check each endpoint for its limits. All errors follow a [standard format](/docs/api-errors). Request rates and field sizes: [Limits](/docs/limits). Model catalog and ZDR coverage: [Models](/docs/models). Next: [Billing & Usage](/docs/billing-usage) · [Errors](/docs/api-errors) · [Pagination](/docs/api-pagination) # Authentication Source: https://www.m8tes.ai/docs/authentication Every request authenticates with an API key in the `Authorization` header. Keys use the `m8_` prefix. ```bash curl "https://api.m8tes.ai/api/v2/agents?user_id=customer_123" \ -H "Authorization: Bearer m8_your_key_here" ``` ```python from m8tes import M8tes client = M8tes(api_key="m8_your_key_here") # or set M8TES_API_KEY and call M8tes() with no args ``` ## Get a key - **In code**: `m8tes.signup(...)` creates an account and returns a key in one call, no dashboard visit. See the [Quick Start](/docs/quickstart). - **Dashboard**: grab or rotate your key at the [Developer dashboard](https://www.m8tes.ai/developer). - **Agent-created accounts**: passwordless signup returns a *setup-only* key that is revoked when the owner activates the account, so an onboarding agent never keeps lasting access. See [Use your coding agent](/docs/build-with-agents). ## Environment conventions | Variable | Purpose | |---|---| | `M8TES_API_KEY` | Secret key used by `M8tes()` and the CLI | | `M8TES_BASE_URL` | Optional custom API URL; include `/api/v2` | ## Rotation and named keys Rotate the default key with `client.keys.rotate()` (or `POST /api/v2/token`). Rotation invalidates the previous key immediately, so update your stored secret in the same motion. For anything beyond a single environment, use **named keys**: independently revocable, optionally expiring. ```python key = client.keys.create(name="staging", expires_in_days=90) print(key.api_key) # full key returned ONCE. Store it now client.keys.list() # named keys, secrets never returned client.keys.rotate(key.id) # rotate one key without touching the others client.keys.revoke(key.id) # stops authenticating immediately ``` One named key per environment means a leaked staging key is revoked on its own, with production untouched. ## Sessions vs. keys The web dashboard signs its own requests with browser session tokens; integrations use `m8_` keys, server-side only. Never ship a key in client-side code. For user-facing UIs, route agent calls through your own backend (the [React embed](/docs/embed-a-ui) does exactly this with a server handler). Sessions are revoked separately from keys, and all at once: ```python client.account.revoke_sessions() # ends every session, on every device ``` Use it when a laptop is lost or a team member leaves. Keys, webhook tokens, and agents mid-run are untouched — revoke a key with `client.keys.revoke(id)`. The call needs a session token: an `m8_` key gets a 403, so a key in CI can never sign the account owner out. ## Multi-tenancy is not per-key Don't mint a key per customer. One account key plus `user_id` on each run scopes memory, history, and tool connections to that end-user automatically. See [Multi-tenancy](/docs/users). Before production: the [API key hygiene checklist](/docs/going-live#api-key-hygiene). # Endpoints Source: https://www.m8tes.ai/docs/api-reference m8tes is the hosted agent platform: agents are API resources with per-user isolation (`user_id`), memory, scheduling, approvals, and 190+ integrations built in. Base URL: `https://api.m8tes.ai/api/v2` Machine-readable schema: `GET https://api.m8tes.ai/api/v2/openapi.json` (V2 routes only; no auth). All requests require an API key in the Authorization header: ``` Authorization: Bearer m8_your_key_here ``` --- ## Signup Create an account and get an API key. No authentication required. See the [Quick Start](/docs/quickstart) for the Python equivalent (`m8tes.signup()`). Runs are prepaid: a new API account includes $1 test credit on `deepseek-v4-1-flash`. For other models or more runs, [top up](https://www.m8tes.ai/developer) or connect a model subscription — a run with no balance fails with `TOKEN_BALANCE_DEPLETED` and a `topup_url`. Email verification is separate: once you can run, you can complete up to 25 runs before verifying (not free credit). ### Signup `POST /signup` | Field | Type | Required | Description | |-------|------|----------|-------------| | email | string | yes | Email address. | | first_name | string | yes | First Name | | password | string | no | Password | | product | string | no | Product | | require_end_user_id | bool | no | Strict user_id enforcement. Defaults to ON for API signups and OFF for Platform. Set false for personal development only; this persists account-wide. For customer-facing apps, keep strict mode on and pass user_id. Change via PATCH /settings. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/signup \ -H "Content-Type: application/json" \ -d '{"email": "founder@acme.com","first_name": "Sam","product": "platform"}' ``` --- ## Token Mint or rotate your API key. Rotating invalidates the previous key immediately. ### Get Token `POST /token` | Field | Type | Required | Description | |-------|------|----------|-------------| | email | string | yes | Email address. | | password | string | yes | Password | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/token \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com","password": "your-password"}' ``` --- ## Verify Email verification is separate. Once you can run (test credit, top-up, or own-subscription OAuth), you can complete up to 25 runs before verifying; after that, runs fail with `EMAIL_VERIFICATION_REQUIRED` until the emailed link is clicked. Poll `status` to learn when that happened. ### Resend Verify `POST /verify/resend` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/verify/resend \ -H "Authorization: Bearer m8_your_key_here" ``` ### Verify Status `GET /verify/status` ```bash curl \ https://api.m8tes.ai/api/v2/verify/status \ -H "Authorization: Bearer m8_your_key_here" ``` --- ## Agents Agents are reusable personas with instructions and tools. Coding: bind repositories under `/agents/{id}/repos` after connecting [GitHub App](#github-app). ### Create Agent `POST /agents` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Defaults to a random name if omitted. Blank strings are rejected. | | instructions | string | no | Agent system-prompt instructions. | | tools | string[] | no | Tool names, e.g. ['gmail', 'slack'] | | enable_memory | bool | no | Default for the memory tool. Null = platform default (on). | | enable_history | bool | no | Default for the task-history tool. Null = platform default (on). | | enable_task_setup_tools | bool | no | Default for the same-scope task-setup tools. Null = platform default (on). | | enable_feedback | bool | no | Default for the issue-reporting feedback tool. Null = platform default (on). | | enable_self_improvement | bool | no | When true, the agent runs a weekly review-and-improve task: it reads its own recent runs and improves itself by rewriting its instructions, refining/creating its tasks, and recording lessons/memory. Implies the task-setup, history, and memory tools. Null/false = off. | | role | string | no | Agent persona role, e.g. 'Customer support specialist'. | | prompt_profile | string | no | Which system prompt this agent runs on. "platform" (default) is the full m8tes prompt: the agent knows it runs on m8tes and can guide the owner through the product. "bare" is for embedding the agent in YOUR product: it never names m8tes, never calls itself a mate, is never told our billing model, and cannot load m8tes' own Agent Skills. It keeps every capability and safety rule; only the branding is removed. Your own skills, tools, and instructions are unaffected. Read the exact prompt an agent will receive with GET /api/v2/agents/{id}/system-prompt. | | disabled_builtin_tools | string[] | no | Built-in tools this agent must NOT be given, by `name` from GET /api/v2/built-in-tools (e.g. ["feedback", "notify", "computer_use"]). Any built-in can be switched off. Useful when embedding an agent in your own product, where our issue-reporting and owner-notify tools have no place. Omit or [] to keep the platform defaults. Unknown names are rejected. | | goals | string | no | Agent goals. Injected into system prompt. | | user_id | string | no | Your end-user ID for data isolation for this agent. | | metadata | object | no | Arbitrary key-value data | | allowed_senders | string[] | no | Email addresses or @domain patterns allowed to email this agent. Overrides default. | | inbound_imessage_enabled | bool | no | Enable inbound iMessage routing for this agent. | | imessage_chat_guid | string | no | BlueBubbles chat GUID used for inbound routing and outbound replies. | | bridge_id | int | no | BlueBubblesBridge to route this agent's iMessage through (account-scoped). | | allowed_imessage_senders | string[] | no | Sender handles (phone/email) allowed to trigger this agent via iMessage. Required (fail-closed) when iMessage is enabled. | | inbound_slack_enabled | bool | no | Deprecated toggle. Prefer slack_channels: assigning channels turns Slack on for this agent; clearing them turns it off. The Lead mate does not need this: it already answers every unbound channel and every DM. | | slack_slug | string | no | Deprecated. Inbound Slack no longer routes by a typed handle; @m8tes in a bound channel reaches this agent. Kept for read compatibility. | | slack_channels | any[] | no | Slack channels this agent answers. @m8tes in one of these rooms reaches this agent; every other channel and every DM reaches the Lead mate. One agent per channel. Omit on create to leave unbound. | | allowed_slack_senders | string[] | no | Slack user IDs allowed to trigger this agent. None/empty = any member of the workspace that installed the app (the installer is always allowed). This list narrows WHO may trigger inside that workspace; it cannot admit someone outside it. A guest in a shared/Slack Connect channel is refused even if listed, because a Slack user ID is scoped to one workspace. | | email_inbox | bool | no | Enable email inbox on creation | | webhook | bool | no | Enable webhook trigger on creation. Returns webhook_url once. | | default_permission_mode | string | no | Default execution mode for direct runs and the starting mode for new tasks. autonomous=auto-approve all tools, approval=pause and ask before tool use, plan=require plan approval before execution. Omitted defaults to approval. | | model | string | no | Model for runs: a short Claude alias (sonnet, opus, fable) or any concentrate catalog id from GET /api/v2/models. Check zdr_supported / zdr_providers on each model (provider capability only, not ZDR on for every route or your account). Omit or null = platform default (the GET /api/v2/models entry flagged default: true, currently deepseek-v4-1-flash at high reasoning effort). Discover ids, prices, and ZDR via GET /api/v2/models. | | effort | string | no | Reasoning effort for runs: low \| medium \| high \| xhigh \| max. Omit or null = platform default (max on Claude models: reason as hard as the model allows; high on all others). Higher effort = deeper reasoning, more tokens, higher cost per run; lower effort = faster and cheaper. xhigh and max are Claude-only tiers: on other models they are clamped down to high, never rejected (each model's ceiling is `max_effort` on GET /api/v2/models). Per-run effort overrides the task's, which overrides the agent's; resumes and replies inherit the run's effort. Effort currently takes effect on Claude models, the GPT-5.6 / GPT-6 families (incl. gpt-6.1-sol / gpt-6-sol / gpt-6-luna / gpt-6-astra), and grok-4.6; the platform-default gateway route (deepseek-v4-1-flash) pins high reasoning effort. Other models accept the field but may ignore it. | | from_template | string | no | Enable a pre-built agent template by slug (e.g. 'ppc-manager'). When set, system_prompt + default tasks + required integrations are applied from the template. The agent stays linked: improvements we ship to the template flow through automatically unless you customize a field (which then takes precedence). Other body fields EXCEPT user_id, metadata, and onboarding/wizard fields may NOT be set alongside from_template. Use PATCH to customize after creation. See GET /api/v2/agent-templates for available slugs. | | onboarding_answers | object | no | Wizard answers keyed by OnboardingQuestion.key. Saved as account-level memories so the agent reads them on every run. Only valid alongside from_template. All keys optional; unknown keys are silently ignored. | | enabled_task_slugs | string[] | no | Which template default_tasks to seed. Null = all of them. Lets the wizard customer uncheck specific recurring tasks before enabling. Only valid alongside from_template. | | enabled_bootstrap_slugs | string[] | no | Which template bootstrap_tasks to seed. Null = all of them. Bootstrap tasks run once on enable. Only valid alongside from_template. | | group_id | int | no | Optional mate group (GET /api/v2/groups) to place this agent in at create. Must be in the same end-user scope. The Lead mate cannot join a group. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support bot","instructions": "Handle tickets","tools": ["gmail"]}' ``` ```python client.agents.create( name="support bot", tools=["gmail"], instructions="Handle tickets" ) ``` **Response** `201 Created` ```json { "id": 1, "name": "support bot", "instructions": "Handle tickets", "tools": [ "gmail" ], "default_permission_mode": "autonomous", "inbound_imessage_enabled": false, "status": "enabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### List Agents `GET /agents` | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | include_archived | bool | Include archived agents (status 'archived') so they can be unarchived. | | fields | string | Optional list projection. Pass `summary` to omit heavy persona fields (instructions, goals, role, metadata, tools, slack channel bindings) for roster/sidenav clients. Sender allowlists still round-trip for managers so channel editors that save from the roster row cannot silently broaden access. Default (omit) keeps the full TeammateResponse for existing SDK and detail consumers. | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/agents?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python page = client.agents.list(user_id="customer_123") for agent in page.data: print(agent.name) ``` All list endpoints return a paginated envelope: ```json { "data": [...], "has_more": true } ``` Use `starting_after` with the last item's ID to fetch the next page. ### Get Agent `GET /agents/{agent_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/agents/1 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python bot = client.agents.get(1) ``` **Response** `200 OK` ```json { "id": 1, "name": "support bot", "instructions": "Handle tickets", "tools": [ "gmail" ], "default_permission_mode": "autonomous", "inbound_imessage_enabled": false, "status": "enabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### Update Agent `PATCH /agents/{agent_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Agent name. | | instructions | string | no | Agent system-prompt instructions. | | tools | string[] | no | Tool names, e.g. ['gmail', 'slack']. | | enable_memory | bool | no | Default for the memory tool. | | enable_history | bool | no | Default for the task-history tool. | | enable_task_setup_tools | bool | no | Default for the same-scope task-setup tools. | | enable_feedback | bool | no | Default for the issue-reporting feedback tool. | | enable_self_improvement | bool | no | Turn the weekly review-and-improve task on/off. When true the agent reads its own runs and improves itself (implies task-setup/history/memory). | | role | string | no | Agent persona role. | | prompt_profile | string | no | Switch this agent between the full m8tes prompt ("platform") and the embedding prompt ("bare"). Takes effect on the agent's next run. See the same field on agent create, and GET /api/v2/agents/{id}/system-prompt to read the exact result. | | disabled_builtin_tools | string[] | no | Built-in tools this agent must NOT be given, by `name` from GET /api/v2/built-in-tools (e.g. ["feedback", "notify", "computer_use"]). Any built-in can be switched off. Useful when embedding an agent in your own product, where our issue-reporting and owner-notify tools have no place. Omit or [] to keep the platform defaults. Unknown names are rejected. | | goals | string | no | Agent goals. Injected into the system prompt. | | metadata | object | no | Arbitrary key-value data. | | display_order | int | no | Manual roster position (lower sorts first). Sort agents by COALESCE(display_order, id) ascending: unplaced agents keep creation order and newly created ones append at the bottom. Send null to un-place the agent (back to creation order). | | group_id | int | no | Move this agent into a mate group (GET /api/v2/groups), or send null to ungroup. Must be same end-user scope. Omit to leave unchanged. Lead mate cannot join a group. | | visibility | string | no | Who can see this agent. Omit or send null to leave unchanged. | | allowed_senders | string[] | no | Email addresses or @domain patterns allowed to email this agent. | | inbound_imessage_enabled | bool | no | Enable inbound iMessage routing for this agent. | | imessage_chat_guid | string | no | BlueBubbles chat GUID used for inbound routing and outbound replies. | | bridge_id | int | no | BlueBubbles bridge to route this agent's iMessage through (account-scoped). | | allowed_imessage_senders | string[] | no | Sender handles (phone/email) allowed to trigger this agent via iMessage. | | inbound_slack_enabled | bool | no | Deprecated toggle. Prefer slack_channels: assigning channels turns Slack on for this agent; clearing them turns it off. | | slack_slug | string | no | Deprecated. Inbound Slack routes by bound channel, not a typed handle. | | slack_channels | any[] | no | Replace this agent's Slack channels. @m8tes in one of these rooms reaches this agent; every other channel and every DM reaches the Lead mate. Pass [] to unbind. Omit to leave unchanged. One agent per channel. | | allowed_slack_senders | string[] | no | Slack user IDs allowed to trigger this agent. None/empty = any member of the INSTALLED workspace. Narrows who may trigger within that workspace; it cannot admit a guest from another workspace. | | default_permission_mode | string | no | Default execution mode; null (or omitting the field) leaves it unchanged. | | model | string | no | Model for runs: any id from GET /api/v2/models (short Claude aliases or concentrate slugs). Check zdr_supported (provider capability only, not 'ZDR always on'). Explicit null resets to the platform default; omitting the field leaves the current model unchanged. | | effort | string | no | Reasoning effort for runs: low \| medium \| high \| xhigh \| max. Omit or null = platform default (max on Claude models: reason as hard as the model allows; high on all others). Higher effort = deeper reasoning, more tokens, higher cost per run; lower effort = faster and cheaper. xhigh and max are Claude-only tiers: on other models they are clamped down to high, never rejected (each model's ceiling is `max_effort` on GET /api/v2/models). Per-run effort overrides the task's, which overrides the agent's; resumes and replies inherit the run's effort. Effort currently takes effect on Claude models, the GPT-5.6 / GPT-6 families (incl. gpt-6.1-sol / gpt-6-sol / gpt-6-luna / gpt-6-astra), and grok-4.6; the platform-default gateway route (deepseek-v4-1-flash) pins high reasoning effort. Other models accept the field but may ignore it. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/agents/1 \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "renamed bot","tools": ["gmail","slack"]}' ``` ```python client.agents.update(1, name="Renamed Bot", tools=["gmail", "slack"]) ``` **Response** `200 OK` ```json { "id": 1, "name": "support bot", "instructions": "Handle tickets", "tools": [ "gmail" ], "default_permission_mode": "autonomous", "inbound_imessage_enabled": false, "status": "enabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### Delete Agent Soft-deletes (archives) the agent. Returns `204 No Content`. `DELETE /agents/{agent_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/agents/1 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.delete(1) ``` ### Disable Agent `POST /agents/{agent_id}/disable` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/disable \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.disable(1) # pause; re-enable with .enable(1) ``` **Response** `200 OK` ```json { "id": 1, "name": "support bot", "instructions": "Handle tickets", "tools": [ "gmail" ], "default_permission_mode": "autonomous", "inbound_imessage_enabled": false, "status": "enabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### Enable Email Inbox `POST /agents/{agent_id}/email-inbox` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/email-inbox \ -H "Authorization: Bearer m8_your_key_here" ``` ### Enable Agent `POST /agents/{agent_id}/enable` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/enable \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.enable(1) ``` **Response** `200 OK` ```json { "id": 1, "name": "support bot", "instructions": "Handle tickets", "tools": [ "gmail" ], "default_permission_mode": "autonomous", "inbound_imessage_enabled": false, "status": "enabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### Enable Fetchmail `POST /agents/{agent_id}/fetchmail` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/fetchmail \ -H "Authorization: Bearer m8_your_key_here" ``` ### Configure Agent Repo `POST /agents/{agent_id}/repos` | Field | Type | Required | Description | |-------|------|----------|-------------| | repo_full_name | string | yes | Repo Full Name | | mode | string | no | Omitted: new bindings default to trusted; reconfigure keeps existing mode | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/repos \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"repo_full_name": "..."}' ``` ```python client.agents.configure_repo(1, repo_full_name="acme/api") ``` ### Reset Agent Overrides `POST /agents/{agent_id}/reset` | Field | Type | Required | Description | |-------|------|----------|-------------| | fields | string[] | no | Fields to reset. None = reset all overrides. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/reset \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"fields": ["instructions"]}' ``` ```python client.agents.reset(1, fields=['instructions']) # clear customization, re-link to template ``` ### Create Slack Channel `POST /agents/{agent_id}/slack-channel` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/slack-channel \ -H "Authorization: Bearer m8_your_key_here" ``` ### Unarchive Agent `POST /agents/{agent_id}/unarchive` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/unarchive \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.unarchive(1) ``` **Response** `200 OK` ```json { "id": 1, "name": "support bot", "status": "disabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### List Agent Documents `GET /agents/{agent_id}/documents` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/agents/1/documents \ -H "Authorization: Bearer m8_your_key_here" ``` ```python docs = client.agents.list_documents(1) ``` ### List Agent Repos `GET /agents/{agent_id}/repos` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ https://api.m8tes.ai/api/v2/agents/1/repos \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.list_repos(1) ``` ### Get Agent System Prompt `GET /agents/{agent_id}/system-prompt` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/agents/1/system-prompt \ -H "Authorization: Bearer m8_your_key_here" ``` ```python print(client.agents.system_prompt(1).system_prompt) ``` ### Set Agent Webhook Enabled `PATCH /agents/{agent_id}/webhook` | Field | Type | Required | Description | |-------|------|----------|-------------| | enabled | bool | yes | True resumes the webhook, False pauses it | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/agents/1/webhook \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"enabled": true}' ``` ```python client.agents.set_webhook_enabled(7, enabled=False) ``` ### Disable Email Inbox `DELETE /agents/{agent_id}/email-inbox` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/agents/1/email-inbox \ -H "Authorization: Bearer m8_your_key_here" ``` ### Disable Fetchmail `DELETE /agents/{agent_id}/fetchmail` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/agents/1/fetchmail \ -H "Authorization: Bearer m8_your_key_here" ``` ### Read Agent Document `GET /agents/{agent_id}/documents/{name}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/agents/1/documents/{name} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python doc = client.agents.get_document(1, "latest-report") ``` **Response** `200 OK` ```json { "id": 3, "name": "latest-report", "summary": "Weekly PPC report", "mime_type": "text/markdown", "size_bytes": 2048, "source": "agent", "source_run_id": 42, "created_at": "2026-01-15T10:00:00Z", "updated_at": "2026-01-22T10:00:00Z" } ``` ### Remove Agent Repo `DELETE /agents/{agent_id}/repos/{repo_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/agents/1/repos/{repo_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.remove_repo(1, 9) ``` ### Approve Agent Repo Commands `POST /agents/{agent_id}/repos/{repo_id}/approve-commands` | Field | Type | Required | Description | |-------|------|----------|-------------| | commands_digest | string | yes | Commands Digest | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/agents/1/repos/{repo_id}/approve-commands \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"commands_digest": "..."}' ``` ```python client.agents.approve_repo_commands(1, 9, commands_digest="…") ``` ### Get Agent Repo Env `GET /agents/{agent_id}/repos/{repo_id}/env` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ https://api.m8tes.ai/api/v2/agents/1/repos/{repo_id}/env \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.get_repo_env(1, 9) ``` ### Set Agent Repo Env `PUT /agents/{agent_id}/repos/{repo_id}/env` | Field | Type | Required | Description | |-------|------|----------|-------------| | env | object | yes | Environment variables the coding run may read (e.g. a test DB URL). Replaces the stored set; a key omitted here is removed. Values are 8-8192 characters: shorter ones cannot be redacted from run output and are refused. | ```bash curl \ -X PUT \ https://api.m8tes.ai/api/v2/agents/1/repos/{repo_id}/env \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"env": {}}' ``` ```python client.agents.set_repo_env(1, 9, env={"DATABASE_URL": "…"}) ``` ### Clear Agent Repo Commands `DELETE /agents/{agent_id}/repos/{repo_id}/commands` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/agents/1/repos/{repo_id}/commands \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.clear_repo_commands(1, 9) ``` ### Delete Agent Repo Env Key `DELETE /agents/{agent_id}/repos/{repo_id}/env/{key}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/agents/1/repos/{repo_id}/env/{key} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.agents.delete_repo_env_key(1, 9, "DATABASE_URL") ``` --- ## Agent-Templates Pre-built agent templates (e.g. `ppc-manager`). Enable one with `agents.create(from_template=...)`; improvements we ship flow through automatically until you customize a field. ### List Agent Templates `GET /agent-templates` ```bash curl \ https://api.m8tes.ai/api/v2/agent-templates ``` ```python page = client.agent_templates.list() ``` **Response** `200 OK` ```json { "data": [ { "slug": "ppc-manager", "name": "Google Ads", "description": "Watches your Google Ads every week. Catches wasted spend.", "logo_ref": "google-ads", "required_integrations": [ "google_ads" ], "role": "Paid search", "goals": "Reduce wasted spend, improve CTR.", "default_tasks": [], "bootstrap_tasks": [], "questions": [] } ], "has_more": false } ``` --- ## Models The model catalog: ids, pricing, ZDR coverage, and reasoning-effort caps for every model an agent or run can use. The `default: true` flag reflects what your account's runs use when no model is set. ### List Models `GET /models` | Parameter | Type | Description | |-----------|------|-------------| | zdr | bool | If set, filter by provider-level ZDR support (zdr_supported). Not the same as ZDR enabled for your concentrate key. | | author | string | Filter by concentrate author/provider slug (e.g. openai, anthropic). | | curated | bool | If true, return only the Platform curated short list (not full catalog). | | limit | int | Max results (1-500, default 500) | ```bash curl \ https://api.m8tes.ai/api/v2/models \ -H "Authorization: Bearer m8_your_key_here" ``` ```python models = client.models.list() ``` --- ## Model-Connections List account-level Claude connection status and manage native OpenAI/Codex, Grok/xAI, or Gemini authorization. Provider credentials are captured automatically, encrypted at rest, and never returned. ### List Model Connections `GET /model-connections` ```bash curl \ https://api.m8tes.ai/api/v2/model-connections \ -H "Authorization: Bearer m8_your_key_here" ``` ```python connections = client.model_connections.list() ``` ### Clear Model Connection Default `DELETE /model-connections/preferred-default` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/model-connections/preferred-default \ -H "Authorization: Bearer m8_your_key_here" ``` ```python result = client.model_connections.clear_default() ``` ### Disconnect Model Connection `DELETE /model-connections/{provider}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/model-connections/{provider} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python connection = client.model_connections.disconnect("openai") ``` ### Paste Claude Connection `POST /model-connections/claude/paste` | Field | Type | Required | Description | |-------|------|----------|-------------| | access_token | string | yes | Access Token | | refresh_token | string | no | Refresh Token | | expires_at_ms | int | no | Expires At Ms | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/model-connections/claude/paste \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"access_token": "..."}' ``` ```python connection = client.model_connections.paste_claude(access_token="sk-ant-oat01-...", refresh_token="sk-ant-ort01-...") ``` ### Apply Model Connection Default `POST /model-connections/{provider}/apply-default` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/model-connections/{provider}/apply-default \ -H "Authorization: Bearer m8_your_key_here" ``` ```python result = client.model_connections.apply_default("claude") ``` ### Start Model Connection Authorization `POST /model-connections/{provider}/authorizations` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/model-connections/{provider}/authorizations \ -H "Authorization: Bearer m8_your_key_here" ``` ```python authorization = client.model_connections.authorize("openai") # also "xai" | "gemini" | "claude" ``` ### Update Provider Default Model `PATCH /model-connections/{provider}/default-model` | Field | Type | Required | Description | |-------|------|----------|-------------| | model | string | yes | Known model ID or alias for this provider; null restores its platform default. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/model-connections/{provider}/default-model \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"model": "..."}' ``` ```python connection = client.model_connections.set_default_model("openai", model="gpt-6-astra") ``` ### Complete Model Connection Authorization `POST /model-connections/{provider}/authorizations/{state}` | Field | Type | Required | Description | |-------|------|----------|-------------| | code | string | yes | Code | | project_id | string | no | Gemini Cloud project ID when the selected tier requires one | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/model-connections/{provider}/authorizations/{state} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"code": "..."}' ``` ```python authorization = client.model_connections.complete_authorization("claude", state, code=code) # or "gemini" ``` ### Poll Model Connection Authorization `GET /model-connections/{provider}/authorizations/{state}` ```bash curl \ https://api.m8tes.ai/api/v2/model-connections/{provider}/authorizations/{state} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python authorization = client.model_connections.authorization_status("openai", state) ``` ### Disconnect Model Connection Account `DELETE /model-connections/{provider}/accounts/{account_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/model-connections/{provider}/accounts/{account_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python connection = client.model_connections.disconnect_account("claude", account_id) ``` ### Cancel Model Connection Authorization `DELETE /model-connections/{provider}/authorizations/{state}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/model-connections/{provider}/authorizations/{state} \ -H "Authorization: Bearer m8_your_key_here" ``` --- ## Runs Runs are task executions. They stream Server-Sent Events by default. ### Create Run `POST /runs` | Field | Type | Required | Description | |-------|------|----------|-------------| | permission_mode | string | no | Execution mode override. If omitted, inherits from the agent default for direct runs or the saved task mode for task runs. autonomous=auto-approve all tools, approval=pause and ask before tool use, plan=require plan approval before execution | | human_in_the_loop | bool | no | Enable human-in-the-loop features: clarifying questions, tool approval, and plan approval. If omitted, approval/plan inherit as true and autonomous inherits as false. | | teammate_id | int | no | ID of the agent to use. | | message | string | yes | The user message that drives the run. | | skill | string | no | Force an invokable skill by slug (custom skill, a platform command like `make-skill`, or a skill committed in the teammate's bound repository). Same as starting the message with `/slug`. See GET /api/v2/skills/invokable. Unknown slugs return 422. A repository skill that cannot be checked because GitHub is unreachable returns 503 with `Retry-After`. | | tools | string[] | no | Tool names, e.g. `gmail`, `slack`. | | model | string | no | Per-run model override: any id from GET /api/v2/models. Omit to use the agent's model, then the platform default. | | effort | string | no | Per-run reasoning-effort override. Reasoning effort for runs: low \| medium \| high \| xhigh \| max. Omit or null = platform default (max on Claude models: reason as hard as the model allows; high on all others). Higher effort = deeper reasoning, more tokens, higher cost per run; lower effort = faster and cheaper. xhigh and max are Claude-only tiers: on other models they are clamped down to high, never rejected (each model's ceiling is `max_effort` on GET /api/v2/models). Per-run effort overrides the task's, which overrides the agent's; resumes and replies inherit the run's effort. Effort currently takes effect on Claude models, the GPT-5.6 / GPT-6 families (incl. gpt-6.1-sol / gpt-6-sol / gpt-6-luna / gpt-6-astra), and grok-4.6; the platform-default gateway route (deepseek-v4-1-flash) pins high reasoning effort. Other models accept the field but may ignore it. | | stream | bool | no | Return Server-Sent Events when `true`, a JSON object when `false`. | | name | string | no | Agent name (creates new if no teammate_id). Blank strings are rejected. | | instructions | string | no | Agent instructions | | user_id | string | no | Your end-user ID for data isolation. If the targeted agent is already scoped, this must match that scope. When omitted, the run inherits the agent's existing scope. | | metadata | object | no | Arbitrary key-value data. Keys reserved for internal use are ignored (backend run markers such as error_code, auto_retryable, interrupted, and nudged_* delivery stamps). | | memory | bool | no | Saved memories in agent context. Omit to inherit the agent default. | | history | bool | no | Previous run results in agent context. Omit to inherit the agent default. | | task_setup_tools | bool | no | Internal same-scope management tools for agents, tasks, runs, approvals, files, memories, schedules, inboxes, webhooks, and app connections. Omit to inherit the agent default. | | feedback | bool | no | Internal issue-reporting feedback tool (`report_issue`). Omit to inherit the agent default. | | email_notifications | bool | no | Send this run's result to the account owner when it finishes and nobody has viewed it (POST /runs/{id}/view) within 5 minutes: a Slack DM when Slack is connected, else email. Skipped when the agent judges it not worth an interruption. First-party runs only. Off by default for one-off runs; the Platform turns it on for the runs it starts. | | email_inbox | bool | no | Enable email inbox on the created agent (only applies when creating new) | | output_schema | object | no | JSON Schema the run's final result must match. The structured result comes back on `output_data` (and on the `run.completed` webhook). Must be `"type": "object"`; inline your definitions ($ref/$defs are not supported). `output_data` is null when the model produced no structured result. Always null-check it. | | max_turns | int | no | Hard cap on conversation turns for each REQUEST on this run (each model reply is one turn). Omit to use the platform default (2000). Persisted on the run, so an automatic continuation of the request in flight — an approval resume, an interrupted-run re-drive — inherits the REMAINING budget (cap - turns already spent on that request) rather than a fresh allotment. A new message the caller sends re-arms the cap. Capped at 2000; the per-run cost watchdog still bounds spend. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"teammate_id": 1,"message": "Close open tickets","stream": false}' ``` ```python # Streaming (default) with client.runs.create( agent_id=1, message="Close open tickets", ) as stream: for event in stream: if event.type == "text-delta": print(event.delta, end="", flush=True) print(stream.text) # Non-streaming run = client.runs.create(agent_id=1, message="Close open tickets", stream=False) ``` **Streaming response:** Server-Sent Events: raw flat Claude-native frames (the SDK normalizes these into `text-delta`, `tool-call-start`, `tool-result-end`): ``` data: {"type": "content_block_delta", "id": "block_1", "delta": {"type": "text_delta", "text": "Hello"}} data: {"type": "tool_use", "id": "tc_1", "name": "gmail_send", "input": {"to": "a@b.com"}} data: {"type": "tool_result", "tool_use_id": "tc_1", "content": "...", "result": "..."} data: {"type": "done", "completion_state": "complete", "stop_reason": "end_turn", "message_count": 2} ``` **Non-streaming response** `200 OK`: ```json { "id": 42, "teammate_id": 1, "status": "running", "output": null, "user_id": "customer_123", "metadata": null, "created_at": "2026-01-15T10:05:00Z" } ``` > **Polling:** Non-streaming runs return immediately with `status: "running"`. Poll `GET /runs/{id}` every 2 seconds until `status` is `completed`, `failed`, or `cancelled`. > **Defaults:** If `permission_mode` is omitted, the run inherits the agent's saved default. If `human_in_the_loop` is omitted, inherited `approval` and `plan` runs automatically enable it. > **Validation:** Explicitly setting `human_in_the_loop=false` is only valid with `permission_mode="autonomous"`. > **Internal tools:** `task_setup_tools=true` by default. Set it to `false` when you do not want the agent to receive the internal same-scope management tools for agents, tasks, runs, approvals, files, memories, schedules, inboxes, webhooks, and app connections during this run. > **End-user scope:** when you target an existing scoped agent, the `user_id` in the request must match that agent's scope. If omitted, the run inherits the agent's existing scope. **Quick start:** omit `teammate_id` and provide `name` to auto-create an agent: ```python with client.runs.create( message="Draft release notes", name="Release Bot", instructions="Write concise release notes" ) as stream: for event in stream: if event.type == "text-delta": print(event.delta, end="", flush=True) print(stream.text) ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### List Runs `GET /runs` | Parameter | Type | Description | |-----------|------|-------------| | teammate_id | int | Filter by agent | | task_id | int | Only runs of this task: the pull-path for a scheduled/webhook task's run history and outputs | | status | string | Filter by status (`running`, `paused`, `awaiting_approval`, `completed`, `failed`, `cancelled`, `closed`, `archived`) | | archived | string | Archived runs: `exclude` (the default — archive means hidden), `only` to read the archive by itself, or `include` for both in one list. | | exclude_platform_runs | bool | Hide the platform's own work: the Lead mate's Day-1 onboarding, its twice-daily pulse, and context maintenance. Chat with the Lead mate still counts, because a user talking to it is the user working. Opt-in: used to ask whether the USER has run anything themselves yet. | | include_end_user_runs | bool | Include this account's end-user (API tenant) runs when `user_id` is omitted. The default is account-level only (`end_user_id IS NULL`), matching /runs/check and /runs/activity, so the operator Activity board does not mix tenant work into first-party runs. The Developer console passes this. Ignored when `user_id` is set. | | sort | string | `created` (newest first) or `priority`: runs needing a human first | | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | fields | string | Optional list projection. Pass `summary` to skip conversation_messages prose batching for platform list clients (sidenav / useUserRuns): closing_preview uses the stamped closing headline only, and latest_message_preview is omitted. Default (omit) keeps the full DevRunResponse including latest_message_preview for existing SDK and detail consumers. | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/runs?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python page = client.runs.list(agent_id=1, user_id="customer_123") for run in page.data: print(run.status) ``` ### Create First Session Run `POST /runs/first-session` | Field | Type | Required | Description | |-------|------|----------|-------------| | permission_mode | string | no | Execution mode override. If omitted, inherits from the agent default for direct runs or the saved task mode for task runs. autonomous=auto-approve all tools, approval=pause and ask before tool use, plan=require plan approval before execution | | human_in_the_loop | bool | no | Enable human-in-the-loop features: clarifying questions, tool approval, and plan approval. If omitted, approval/plan inherit as true and autonomous inherits as false. | | teammate_id | int | yes | ID of the agent to use. | | stream | bool | no | Return Server-Sent Events when `true`, a JSON object when `false`. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/first-session \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"teammate_id": 1}' ``` ```python with client.runs.start_first_session(agent_id=1) as stream: for event in stream: print(event) ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Create Run With Files Multipart form-data: the `payload` field carries the exact `POST /runs` JSON body; `files` carries the attachments. ```bash curl -X POST https://api.m8tes.ai/api/v2/runs/with-files \\ -H "Authorization: Bearer m8_your_key_here" \\ -F 'payload={"teammate_id": 1, "message": "Summarize this", "stream": false}' \\ -F 'files=@report.pdf' ``` `POST /runs/with-files` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/with-files \ -H "Authorization: Bearer m8_your_key_here" ``` ```python run = client.runs.create( agent_id=1, message="Summarize this spreadsheet", files=["q3-report.xlsx"], stream=False, ) ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Get Run Activity `GET /runs/activity` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/runs/activity?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python snapshot = client.runs.activity(user_id="customer_123") for agent in snapshot.data: for run in agent.runs: print(agent.agent_id, run.id, run.status) ``` ### Check Run Freshness `GET /runs/check` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/runs/check?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python freshness = client.runs.check() ``` ### List Needs You `GET /runs/needs-you` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/runs/needs-you?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python needs = client.runs.needs_you() print(needs.count, needs.items) ``` ### Get Run `GET /runs/{run_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/runs/42 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python run = client.runs.get(42, user_id="alice") ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Answer Question `POST /runs/{run_id}/answer` | Field | Type | Required | Description | |-------|------|----------|-------------| | answers | object | yes | Map of question text to selected option label | | request_id | string | no | Optional permission request_id for this AskUserQuestion. Required when multiple questions are pending so the answer targets the right gate (Platform dogfood / multi-pending). Omit to answer the first unanswered ask. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/answer \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"answers": {"What priority?": "High"},"request_id": "req_abc123"}' ``` ```python client.runs.answer(42, answers={"What priority?": "High"}) ``` Use this for AskUserQuestion responses. - If the run is `running`, the answer is stored and picked up by the active run. - If the run is `awaiting_approval`, the answer resumes execution. - If the run is terminal (`completed`/`failed`/`cancelled`), the API returns `409`. Plan mode approvals use this endpoint with `{"Plan Approval": "Approve"}`. ### Approve Permission `POST /runs/{run_id}/approve` | Field | Type | Required | Description | |-------|------|----------|-------------| | request_id | string | yes | Permission request ID | | decision | string | yes | `allow` or `deny` the pending tool request. | | remember | bool | no | Remember this decision for subsequent matching tool requests during the current run, including after the run pauses and resumes. With decision=allow this also stores a cross-run always-allow policy for the tool (the same one /permissions manages); the response's `remembered` field reports whether that policy actually persisted. Tools whose meaning requires a fresh human decision cannot be remembered. Re-sending the same decision can upgrade this to true, but cannot turn it back off: the field defaults to false, so an omitted flag is indistinguishable from an explicit opt-out and never revokes a grant. To stop remembering, deny the next request. | | reason | string | no | Optional steering in your own words, e.g. with decision=deny, "use the staging board instead". The agent reads it and adapts rather than silently skipping the action. Delivered on both decisions when the run is paused; a deny's reason also reaches a still-live run. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/approve \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"request_id": "req_abc123","decision": "allow","remember": true}' ``` > **Response status:** after approval this returns a resolved status (`allowed` or `denied`), not `pending`. The response's `resumed` and `remembered` fields report what actually happened: whether a paused run restarted, and whether an always-allow policy persisted. > **Remember behavior:** `remember=true` applies to subsequent matching requests during the current run, and with `decision=allow` it also stores a persistent cross-run always-allow policy (the same one `POST /permissions` manages; use that endpoint to pre-configure or revoke). Check `remembered` in the response: the backend refuses to store a policy it would never consult (e.g. strict human-decision tools). **Response** `200 OK` ```json { "request_id": "req_abc123", "tool_name": "gmail_send", "tool_input": { "to": "user@example.com" }, "status": "allowed", "created_at": "2026-01-15T10:05:30Z", "resolved_at": "2026-01-15T10:05:45Z" } ``` ### Archive Run `POST /runs/{run_id}/archive` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/archive \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.runs.archive(42) ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Cancel Run Cancel an active run. Returns `409` if the run is already completed, failed, or cancelled. `POST /runs/{run_id}/cancel` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/cancel \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.runs.cancel(42, user_id="alice") ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Desktop Ticket `POST /runs/{run_id}/desktop-ticket` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/desktop-ticket \ -H "Authorization: Bearer m8_your_key_here" ``` ```python ticket = client.runs.desktop_ticket(42) # {"ticket": "..."} or ended sentence ``` ### Reply To Run Send a follow-up message on an existing run to continue the conversation. > **Behavior note:** `runs.reply()` inherits the run's settings: the permission mode (`approval`/`plan` gates keep applying) and whether the agent may ask questions (`human_in_the_loop`, as persisted at run creation). Pass `human_in_the_loop: false` on the reply to pin the legacy always-non-interactive behavior. Runs created before this setting existed stay non-interactive. > **Internal tools:** replies inherit the previous run's `task_setup_tools` setting unless you override it in the request body. Tools are automatically inherited from the previous run; to use different tools, start a new run. `POST /runs/{run_id}/reply` | Field | Type | Required | Description | |-------|------|----------|-------------| | message | string | yes | The user message that drives the run. | | skill | string | no | Force an invokable skill by slug for this reply (custom skill, a platform command like `make-skill`, or a skill committed in the teammate's bound repository). Same as starting the message with `/slug`. See GET /api/v2/skills/invokable. | | stream | bool | no | Return Server-Sent Events when `true`, a JSON object when `false`. | | reset_auto_recovery | bool | no | Grant a fresh automatic recovery budget for an explicit continuation. Applies once when this reply is accepted, including queued replies; cumulative retry history is preserved. Automated recovery loops should leave this false. | | tools | string[] | no | Override this reply's app toolset (tool names from GET /api/v2/apps). Omitted or [] = inherit the run's current set ([] is NOT 'no tools'). A changed set persists to the run, so later replies inherit it. Custom MCP server slugs are not accepted here (422). | | permission_mode | string | no | Execution-mode override for this and later replies. Omitted = inherit the mode persisted on the run (the mode it last ran with). | | task_setup_tools | bool | no | Override whether the internal same-scope management tools are enabled for this reply. When omitted, inherits the previous run's setting. | | feedback | bool | no | Override whether the internal issue-reporting feedback tool (`report_issue`) is enabled for this reply. When omitted, inherits the previous run's setting. | | human_in_the_loop | bool | no | Override whether the agent may ask questions (AskUserQuestion) during this reply. When omitted, inherits the setting persisted at run creation (runs created before this field existed stay non-interactive). Pass false to pin the legacy always-non-interactive reply behavior. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/reply \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"message": "What about VIP tickets?","stream": false}' ``` ```python with client.runs.reply( run_id=42, message="What about VIP tickets?", ) as stream: for event in stream: if event.type == "text-delta": print(event.delta, end="", flush=True) print(stream.text) ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Retry Run `POST /runs/{run_id}/retry` | Parameter | Type | Description | |-----------|------|-------------| | confirm | bool | Acknowledge that retrying may repeat actions the run already took. Required when the run performed non-read-only work. | | use_credits | bool | Retry on the platform default model (m8tes credits) instead of replaying the original run's concrete model. Use after clearing a broken model-plan default so OAuth/subscription failures do not re-pin the same credential. Without this flag, provider auth, quota, own-subscription rate-limit, and availability failures are the one exception to concrete-model replay: recovery resolves the account's current connected/preferred provider and avoids the failed provider when an alternative is connected. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/retry \ -H "Authorization: Bearer m8_your_key_here" ``` ```python # Retry a failed run. Creates a NEW run; poll the returned id, not the old one retry = client.runs.retry(42) assert retry.id != 42 # A run that already took actions needs confirm=True retry = client.runs.retry(42, confirm=True) # After a broken model-plan default, pin m8tes credits retry = client.runs.retry(42, use_credits=True) ``` **Response** `201 Created` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Share Run `POST /runs/{run_id}/share` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/share \ -H "Authorization: Bearer m8_your_key_here" ``` ```python share = client.runs.share(42) # share.share_url ``` ### Unarchive Run `POST /runs/{run_id}/unarchive` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/unarchive \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.runs.unarchive(42) ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Mark Run Viewed `POST /runs/{run_id}/view` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/view \ -H "Authorization: Bearer m8_your_key_here" ``` ```python run = client.runs.mark_viewed(42, user_id="alice") ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Read Desktop `GET /runs/{run_id}/desktop` ```bash curl \ https://api.m8tes.ai/api/v2/runs/42/desktop \ -H "Authorization: Bearer m8_your_key_here" ``` ```python status = client.runs.desktop(42) # "live" or ended sentence ``` ### List Run Messages `GET /runs/{run_id}/messages` | Parameter | Type | Description | |-----------|------|-------------| | include_trace | bool | Also return each turn's raw stream events as event_metadata.event_trace. For debugging stream rendering; large, so off by default. | | after_sequence | int | Return only messages with sequence greater than this value | | before_sequence | int | Return the newest messages with sequence less than this value, oldest of that page first | | tail | bool | Return the newest page, oldest of that page first, instead of the oldest page | | limit | int | Max results (1-1000, default 500) | ```bash curl \ https://api.m8tes.ai/api/v2/runs/42/messages \ -H "Authorization: Bearer m8_your_key_here" ``` ```python for msg in client.runs.messages(42): print(msg.role, msg.content[:80] if msg.content else "") ``` ### Get Run Outcome `GET /runs/{run_id}/outcome` ```bash curl \ https://api.m8tes.ai/api/v2/runs/42/outcome \ -H "Authorization: Bearer m8_your_key_here" ``` ```python outcome = client.runs.outcome(42) print(outcome.summary, outcome.cost_usd) ``` **Response** `200 OK` ```json { "run_id": 42, "status": "completed", "summary": "Audit done: paused 3 wasteful keywords, saving ~$120/mo.", "headline": "wasted spend cut", "needs_reply": false, "delivery_channel": "email", "message_count": 14, "input_tokens": 48210, "output_tokens": 3120, "total_tokens": 51330, "cost_usd": "0.4831" } ``` ### List Run Permissions Returns pending and resolved tool permission requests for runs using `approval` or `plan` mode. This list can include `tool_name="AskUserQuestion"` entries. `GET /runs/{run_id}/permissions` ```bash curl \ https://api.m8tes.ai/api/v2/runs/42/permissions \ -H "Authorization: Bearer m8_your_key_here" ``` ### Join Run Stream `GET /runs/{run_id}/stream` ```bash curl \ https://api.m8tes.ai/api/v2/runs/42/stream \ -H "Authorization: Bearer m8_your_key_here" ``` ```python for event in client.runs.stream(run_id=42): ... ``` ### Update Permission Mode `PATCH /runs/{run_id}/permission-mode` | Field | Type | Required | Description | |-------|------|----------|-------------| | permission_mode | string | yes | Execution mode: `autonomous`, `approval`, or `plan`. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/runs/42/permission-mode \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"permission_mode": "autonomous"}' ``` ```python client.runs.update_permission_mode( run_id=42, permission_mode="autonomous" ) ``` > **Mid-run behavior:** Call this while the run is `running` or `awaiting_approval`. > **Autonomous switch:** changing to `autonomous` auto-approves pending tool approval requests and resumes a paused tool approval run. > **Questions still wait:** `AskUserQuestion` and plan approvals are not auto-answered. Use `POST /runs/{id}/answer` for those pauses. ### Unshare Run `DELETE /runs/{run_id}/share` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/runs/42/share \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.runs.unshare(42) ``` ### Reply To Run With Files `POST /runs/{run_id}/reply/with-files` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/runs/42/reply/with-files \ -H "Authorization: Bearer m8_your_key_here" ``` ```python run = client.runs.reply( run_id=42, message="Compare against this spreadsheet", files=["q4-report.xlsx"], stream=False, ) ``` **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` --- ## Audit-Logs Audit logs provide account-scoped request history for the v2 API. ### List Audit Logs `GET /audit-logs` | Parameter | Type | Description | |-----------|------|-------------| | action | string | Filter by action: list, read, create, update, delete | | resource_type | string | Filter by resource type (for example run or task.trigger) | | method | string | Filter by HTTP method: GET, POST, PATCH, PUT, DELETE | | status_code | int | Filter by HTTP status code | | auth | string | Filter by how the request was authenticated: api_key (SDK/API calls made with an m8_ key), dashboard (web app sessions and auth/tool events), or all. Defaults to all so this stays a complete security trail. | | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | ```bash curl \ https://api.m8tes.ai/api/v2/audit-logs \ -H "Authorization: Bearer m8_your_key_here" ``` --- ## Tasks Tasks are reusable job definitions. Create a task, then attach triggers via `tasks.triggers.create()`. ### Create Task `POST /tasks` | Field | Type | Required | Description | |-------|------|----------|-------------| | teammate_id | int | yes | ID of the agent to use. | | name | string | no | Display name. | | instructions | string | yes | Instructions | | tools | string[] | no | Tool names, e.g. `gmail`, `slack`. | | enable_memory | bool | no | Default for the memory tool. | | enable_history | bool | no | Default for the task-history tool. | | enable_task_setup_tools | bool | no | Default for the same-scope task-setup tools. | | enable_feedback | bool | no | Default for the issue-reporting feedback tool. | | enable_lessons | bool | no | Whether this task's agent accumulates self-improvement lessons across its runs. Task-level only (no agent/run cascade). | | expected_output | string | no | Description of expected output format. | | goals | string | no | Task-specific goals. | | user_id | string | no | Your end-user ID for data isolation. If the targeted agent is already scoped, this must match that scope. When omitted, the task inherits the agent's existing scope. | | email_notifications | bool | no | Send email notification when scheduled run completes | | webhook | bool | no | Enable webhook trigger on creation. Returns webhook_url once. | | schedule | string | no | Cron expression, e.g. '0 9 * * 1-5' | | schedule_timezone | string | no | Timezone for schedule, e.g. 'America/New_York' | | model | string | no | Model for this task's runs, overriding the agent's. Same ids as the agent `model` field. Omit or null = inherit the agent's model (then the platform default). Use it to put one expensive task on a stronger model without moving the agent's other work. | | effort | string | no | Reasoning effort for this task's runs, overriding the agent's. Same tiers as the agent `effort` field. Omit or null = inherit the agent's effort. A tier above the resolved model's `max_effort` is clamped down, never rejected. | | permission_mode | string | no | How this task's runs handle approvals: autonomous, approval, or plan. Defaults to the agent's mode. | | workflow_stage | string | no | Board position for this task. Omit to start unassigned. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/tasks \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"teammate_id": 1,"name": "Ticket Closer","instructions": "Close open support tickets"}' ``` ```python task = client.tasks.create( agent_id=1, instructions="Close open support tickets", name="Ticket Closer" ) ``` **Response** `201 Created` ```json { "id": 10, "teammate_id": 1, "name": "Ticket Closer", "instructions": "Close open support tickets", "tools": [], "app_trigger_count": 0, "email_notifications": true, "status": "enabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### List Tasks `GET /tasks` | Parameter | Type | Description | |-----------|------|-------------| | teammate_id | int | Filter by agent | | include_archived | bool | Include archived tasks. Off by default so the common case stays clean. | | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/tasks?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python page = client.tasks.list(agent_id=1, user_id="customer_123") ``` ### Get Task `GET /tasks/{task_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/tasks/10 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python task = client.tasks.get(10) ``` **Response** `200 OK` ```json { "id": 10, "teammate_id": 1, "name": "Ticket Closer", "instructions": "Close open support tickets", "tools": [], "app_trigger_count": 0, "email_notifications": true, "status": "enabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### Update Task `PATCH /tasks/{task_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Display name. | | instructions | string | no | Instructions | | tools | string[] | no | Tool names, e.g. `gmail`, `slack`. | | enable_memory | bool | no | Default for the memory tool. | | enable_history | bool | no | Default for the task-history tool. | | enable_task_setup_tools | bool | no | Default for the same-scope task-setup tools. | | enable_feedback | bool | no | Default for the issue-reporting feedback tool. | | enable_lessons | bool | no | Whether this task accumulates self-improvement lessons. | | expected_output | string | no | Description of the expected output format. | | goals | string | no | Goals | | email_notifications | bool | no | Email the responsible human when a scheduled run completes. | | model | string | no | Model for this task's runs, overriding the agent's. Same ids as the agent `model` field. Omit or null = inherit the agent's model (then the platform default). Use it to put one expensive task on a stronger model without moving the agent's other work. | | effort | string | no | Reasoning effort for this task's runs, overriding the agent's. Same tiers as the agent `effort` field. Omit or null = inherit the agent's effort. A tier above the resolved model's `max_effort` is clamped down, never rejected. | | permission_mode | string | no | How this task's runs handle approvals: autonomous, approval, or plan. Omit or pass null to leave unchanged. | | status | string | no | Enable or disable the task. Disabling pauses its schedules and event triggers; re-enabling re-arms the paused schedules, but event triggers stay off until re-enabled explicitly (PATCH the trigger). | | workflow_stage | string | no | Move this task on its workflow board. Omit to leave unchanged. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/tasks/10 \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"instructions": "Updated instructions"}' ``` ```python client.tasks.update(10, instructions="Updated instructions") ``` **Response** `200 OK` ```json { "id": 10, "teammate_id": 1, "name": "Ticket Closer", "instructions": "Close open support tickets", "tools": [], "app_trigger_count": 0, "email_notifications": true, "status": "enabled", "created_at": "2026-01-15T10:00:00Z" } ``` ### Delete Task `DELETE /tasks/{task_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/tasks/10 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.tasks.delete(10) ``` ### Run Task `POST /tasks/{task_id}/runs` | Field | Type | Required | Description | |-------|------|----------|-------------| | permission_mode | string | no | Execution mode override. If omitted, inherits from the agent default for direct runs or the saved task mode for task runs. autonomous=auto-approve all tools, approval=pause and ask before tool use, plan=require plan approval before execution | | human_in_the_loop | bool | no | Enable human-in-the-loop features: clarifying questions, tool approval, and plan approval. If omitted, approval/plan inherit as true and autonomous inherits as false. | | stream | bool | no | Return Server-Sent Events when `true`, a JSON object when `false`. | | model | string | no | Per-run model override: any id from GET /api/v2/models. Omit to use the agent's model, then the platform default. | | effort | string | no | Per-run reasoning-effort override. Reasoning effort for runs: low \| medium \| high \| xhigh \| max. Omit or null = platform default (max on Claude models: reason as hard as the model allows; high on all others). Higher effort = deeper reasoning, more tokens, higher cost per run; lower effort = faster and cheaper. xhigh and max are Claude-only tiers: on other models they are clamped down to high, never rejected (each model's ceiling is `max_effort` on GET /api/v2/models). Per-run effort overrides the task's, which overrides the agent's; resumes and replies inherit the run's effort. Effort currently takes effect on Claude models, the GPT-5.6 / GPT-6 families (incl. gpt-6.1-sol / gpt-6-sol / gpt-6-luna / gpt-6-astra), and grok-4.6; the platform-default gateway route (deepseek-v4-1-flash) pins high reasoning effort. Other models accept the field but may ignore it. | | user_id | string | no | Your end-user ID for data isolation. If the saved task is already scoped, this must match that scope. When omitted, the run inherits the task's existing scope. | | metadata | object | no | Arbitrary key-value data. Keys reserved for internal use are ignored (backend run markers such as error_code, auto_retryable, interrupted, and nudged_* delivery stamps). | | output_schema | object | no | JSON Schema the run's final result must match. The structured result comes back on `output_data` (and on the `run.completed` webhook). Must be `"type": "object"`; inline your definitions ($ref/$defs are not supported). `output_data` is null when the model produced no structured result. Always null-check it. Applies to THIS run only: a scheduled run of the same task does not inherit it. | | max_turns | int | no | Hard cap on conversation turns for each REQUEST on this run (each model reply is one turn). Omit to use the platform default (2000). Persisted on the run, so an automatic continuation of the request in flight — an approval resume, an interrupted-run re-drive — inherits the REMAINING budget (cap - turns already spent on that request) rather than a fresh allotment. A new message the caller sends re-arms the cap. Capped at 2000; the per-run cost watchdog still bounds spend. Applies to THIS run only. | | memory | bool | no | Saved memories in agent context. Omit to inherit the agent default. | | history | bool | no | Previous run results in agent context. Omit to inherit the agent default. | | task_setup_tools | bool | no | Internal same-scope management tools for agents, tasks, runs, approvals, files, memories, schedules, inboxes, webhooks, and app connections. Omit to inherit the agent default. | | feedback | bool | no | Internal issue-reporting feedback tool (`report_issue`). Omit to inherit the agent default. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/tasks/10/runs \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"stream": false}' ``` ```python # Streaming (default) with client.tasks.run(task_id=10) as stream: for event in stream: if event.type == "text-delta": print(event.delta, end="", flush=True) print(stream.text) # Non-streaming run = client.tasks.run(task_id=10, stream=False) ``` > **Defaults:** If `permission_mode` is omitted, the run inherits the saved task's permission mode. If `human_in_the_loop` is omitted, inherited `approval` and `plan` runs automatically enable it. > **Validation:** Explicitly setting `human_in_the_loop=false` is only valid with `permission_mode="autonomous"`. > **Internal tools:** `task_setup_tools=true` by default. Set it to `false` to run a saved task without the internal same-scope management tools. > **End-user scope:** when the saved task is already scoped, the `user_id` in the request must match that scope. If omitted, the run inherits the task's existing scope. **Response** `200 OK` ```json { "id": 42, "teammate_id": 1, "task_id": 10, "status": "running", "user_id": "customer_123", "permission_mode": "autonomous", "created_at": "2026-01-15T10:05:00Z" } ``` ### Create Trigger `POST /tasks/{task_id}/triggers` | Field | Type | Required | Description | |-------|------|----------|-------------| | type | string | yes | Trigger type, e.g. `schedule`, `webhook`, or an app event. | | cron | string | no | Cron expression (for schedule triggers). | | interval_seconds | int | no | Interval in seconds (for recurring triggers). | | run_at | string | no | ISO 8601 datetime for a ONE-TIME run (e.g. '2027-07-20T09:00:00'). Fires once, then retires itself. Naive values are read in `timezone`. | | timezone | string | no | IANA timezone for schedule evaluation. | | app | string | no | App name, e.g. 'github' | | trigger_name | string | no | Trigger slug, e.g. 'GITHUB_COMMIT_EVENT' | | trigger_config | object | no | Trigger-specific config | | user_id | string | no | End-user whose connected account to use | | allowed_senders | string[] | no | Allowed Senders | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/tasks/10/triggers \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"type": "schedule","cron": "0 9 * * 1","timezone": "America/New_York"}' ``` ```python trigger = client.tasks.triggers.create( task_id=10, type="schedule", cron="0 9 * * 1", timezone="America/New_York" ) ``` **Response** `201 Created` ```json { "id": "schedule_5", "type": "schedule", "enabled": true, "cron": "0 9 * * 1", "timezone": "America/New_York" } ``` ### Enable Task Webhook `POST /tasks/{task_id}/webhook` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/tasks/10/webhook \ -H "Authorization: Bearer m8_your_key_here" ``` ```python hook = client.tasks.enable_webhook(10) ``` ### List Triggers `GET /tasks/{task_id}/triggers` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/tasks/10/triggers \ -H "Authorization: Bearer m8_your_key_here" ``` ```python triggers = client.tasks.triggers.list(10) ``` ### Set Task Webhook Enabled `PATCH /tasks/{task_id}/webhook` | Field | Type | Required | Description | |-------|------|----------|-------------| | enabled | bool | yes | True resumes the webhook, False pauses it | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/tasks/10/webhook \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"enabled": true}' ``` ```python client.tasks.set_webhook_enabled(10, enabled=False) ``` ### Disable Task Webhook `DELETE /tasks/{task_id}/webhook` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/tasks/10/webhook \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.tasks.disable_webhook(10) ``` ### Update Trigger `PATCH /tasks/{task_id}/triggers/{trigger_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | enabled | bool | no | Enabled | | cron | string | no | Cron expression (for schedule triggers). | | interval_seconds | int | no | Interval in seconds (for recurring triggers). | | run_at | string | no | ISO 8601 datetime. Reshapes this trigger into a one-time run. | | timezone | string | no | IANA timezone for schedule evaluation. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/tasks/10/triggers/schedule_5 \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"enabled": true}' ``` ```python client.tasks.triggers.update(task_id=10, trigger_id="schedule_5", enabled=False) ``` **Response** `200 OK` ```json { "id": "schedule_5", "type": "schedule", "enabled": true, "cron": "0 9 * * 1", "timezone": "America/New_York" } ``` ### Delete Trigger `DELETE /tasks/{task_id}/triggers/{trigger_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/tasks/10/triggers/schedule_5 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.tasks.triggers.delete(task_id=10, trigger_id="schedule_5") ``` --- ## Triggers List schedule, webhook, email, and app triggers across the account without fetching each task separately. ### List Triggers `GET /triggers` | Parameter | Type | Description | |-----------|------|-------------| | type | string | | | task_id | int | | | limit | int | Max results (1-100, default 20) | | starting_after | string | Cursor from the last item: `:`. | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/triggers?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python page = client.triggers.list(type="schedule", user_id="customer_123") ``` --- ## Lessons Self-improvement lessons a task's agent writes for itself across runs. The agent authors them during runs; via the API you can list or clear them per task. ### Clear Lessons `POST /tasks/{task_id}/lessons:clear` | Parameter | Type | Description | |-----------|------|-------------| | confirm | bool | Required to confirm. Pass ?confirm=true to actually clear. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/tasks/10/lessons:clear \ -H "Authorization: Bearer m8_your_key_here" ``` **Response** `200 OK` ```json { "capacity_limit": 20, "capacity_used": 0, "data": [] } ``` ### List Lessons `GET /tasks/{task_id}/lessons` ```bash curl \ https://api.m8tes.ai/api/v2/tasks/10/lessons \ -H "Authorization: Bearer m8_your_key_here" ``` **Response** `200 OK` ```json { "capacity_limit": 20, "capacity_used": 0, "data": [] } ``` ### Delete Lesson `DELETE /tasks/{task_id}/lessons/{lesson_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/tasks/10/lessons/{lesson_id} \ -H "Authorization: Bearer m8_your_key_here" ``` **Response** `200 OK` ```json { "capacity_limit": 20, "capacity_used": 0, "data": [] } ``` --- ## Apps List available tools, connect integrations via OAuth, and manage end-user connections. ### List Apps Pass `user_id` to check connection status for a specific end-user. `GET /apps` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/apps?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python apps = client.apps.list() apps = client.apps.list(user_id="cust_123") ``` ### List Connections `GET /apps/connections` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/apps/connections?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python connections = client.apps.connections.list() connections = client.apps.connections.list(user_id="cust_123") ``` ### Claim Slack Install `POST /apps/{app_name}/claim` | Field | Type | Required | Description | |-------|------|----------|-------------| | ticket | string | yes | Ticket | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/apps/gmail/claim \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"ticket": "..."}' ``` ```python result = client.apps.claim("slack:messaging", ticket="...") ``` ### Connect App `POST /apps/{app_name}/connect` | Field | Type | Required | Description | |-------|------|----------|-------------| | redirect_uri | string | yes | URL to redirect after OAuth. For end-user connections (user_id set) this is your own callback. For ACCOUNT-level connections it must be a m8tes URL (https://www.m8tes.ai/apps): the claim ticket that authorizes completion is delivered here, so it has to reach the browser that authorized rather than the caller that created the link. That means an account-level connect finishes in the browser, not in your backend. Read composio_claim off the URL you land on and pass it to connect/complete. To connect on behalf of one of your own users from your own app, pass user_id: that keeps your callback and needs no ticket. | | user_id | string | no | End-user ID for multi-tenant connections | | add_account | bool | no | Connect ANOTHER account of this app next to the existing one (a second Gmail inbox) instead of replacing it. Agents then pick the account per tool call. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/apps/gmail/connect \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"redirect_uri": "https://yourapp.com/callback","user_id": "cust_123"}' ``` ```python conn = client.apps.connect_oauth("gmail", redirect_uri="https://yourapp.com/callback", user_id="cust_123") print(conn.authorization_url) # Redirect your user here print(conn.connection_id) # Save for the next step ``` **Response** `200 OK` ```json { "authorization_url": "https://accounts.google.com/o/oauth2/auth?...", "connection_id": "conn_abc123" } ``` ### Start Slack Install `POST /apps/{app_name}/install` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/apps/gmail/install \ -H "Authorization: Bearer m8_your_key_here" ``` ```python link = client.apps.install("slack:messaging") ``` ### Provision App For apps with `auth_type: "platform_provisioned"` (e.g. `twilio`), the platform allocates a dedicated resource (a phone number) rather than you supplying credentials. Pass `user_id` to provision a per-end-user resource (strictly isolated at run time); omit it for an account-level resource. Release it with `DELETE /apps/{app_name}/connections` (`client.apps.release(...)`). `POST /apps/{app_name}/provision` | Field | Type | Required | Description | |-------|------|----------|-------------| | user_id | string | no | End-user ID to provision for (omit for account-level) | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/apps/twilio/provision \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"user_id": "cust_123"}' ``` ```python result = client.apps.provision("twilio", user_id="cust_123") print(result.phone_number) # "+15551234567" client.apps.release("twilio", user_id="cust_123") # release it back ``` **Response** `200 OK` ```json { "status": "provisioned", "app": "twilio", "phone_number": "+15551234567" } ``` ### List Slack Channels `GET /apps/{app_name}/channels` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ https://api.m8tes.ai/api/v2/apps/slack:messaging/channels \ -H "Authorization: Bearer m8_your_key_here" ``` ```python channels = client.apps.list_channels("slack:messaging") ``` ### List App Connections `GET /apps/{app_name}/connections` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/apps/gmail/connections \ -H "Authorization: Bearer m8_your_key_here" ``` ```python connections = client.apps.connections.list("gmail", user_id="cust_123") connections = client.apps.connections.list("google:ads") ``` ### List Google Ads Customers `GET /apps/{app_name}/customers` | Parameter | Type | Description | |-----------|------|-------------| | refresh | bool | | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/apps/gmail/customers \ -H "Authorization: Bearer m8_your_key_here" ``` ```python customers = client.apps.list_customers("google:ads", user_id="cust_123") ``` ### List Slack Members `GET /apps/{app_name}/members` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ https://api.m8tes.ai/api/v2/apps/gmail/members \ -H "Authorization: Bearer m8_your_key_here" ``` ```python members = client.apps.list_members("slack:messaging") ``` ### List Google Search Console Sites `GET /apps/{app_name}/sites` | Parameter | Type | Description | |-----------|------|-------------| | refresh | bool | | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/apps/gmail/sites \ -H "Authorization: Bearer m8_your_key_here" ``` ```python sites = client.apps.list_sites("google:search_console", user_id="cust_123") ``` ### List App Tools `GET /apps/{app_name}/tools` ```bash curl \ https://api.m8tes.ai/api/v2/apps/gmail/tools \ -H "Authorization: Bearer m8_your_key_here" ``` ```python tools = client.apps.list_tools("github").data ``` ### List App Trigger Types `GET /apps/{app_name}/triggers` ```bash curl \ https://api.m8tes.ai/api/v2/apps/gmail/triggers \ -H "Authorization: Bearer m8_your_key_here" ``` ```python trigger_types = client.apps.list_triggers("github") ``` ### List Slack Workspaces `GET /apps/{app_name}/workspaces` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ https://api.m8tes.ai/api/v2/apps/gmail/workspaces \ -H "Authorization: Bearer m8_your_key_here" ``` ```python workspaces = client.apps.list_workspaces("slack:messaging") ``` ### Update App Connection `PATCH /apps/connections/{connection_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | label | string | no | Name agents and people use for the account | | notes | string | no | What this account is for, in plain words | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/apps/connections/{connection_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"label": "ops@acme.com","notes": "Customer support replies only"}' ``` ```python conn = client.apps.connections.update( 42, label="ops@acme.com", notes="Customer support replies only" ) ``` **Response** `200 OK` ```json { "connection_id": "ca_abc123", "status": "active", "account_label": "ops@example.com", "scopes": [ "https://mail.google.com/" ], "updated_at": "2026-08-18T12:00:00Z" } ``` ### Select Google Ads Customer `PUT /apps/{app_name}/customer` | Field | Type | Required | Description | |-------|------|----------|-------------| | user_id | string | no | End-user connection scope | | account_id | string | yes | Account Id | ```bash curl \ -X PUT \ https://api.m8tes.ai/api/v2/apps/gmail/customer \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"account_id": "..."}' ``` ```python client.apps.select_customer("google:ads", "1234567890", user_id="cust_123") ``` ### Select Google Search Console Site `PUT /apps/{app_name}/site` | Field | Type | Required | Description | |-------|------|----------|-------------| | user_id | string | no | End-user connection scope | | account_id | string | yes | Account Id | ```bash curl \ -X PUT \ https://api.m8tes.ai/api/v2/apps/gmail/site \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"account_id": "..."}' ``` ```python client.apps.select_site("google:search_console", "sc-domain:example.com", user_id="cust_123") ``` ### Delete App Connection `DELETE /apps/connections/{connection_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/apps/connections/{connection_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.apps.connections.delete(42) ``` ### Disconnect App `DELETE /apps/{app_name}/connections` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/apps/gmail/connections \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.apps.disconnect("gmail", user_id="cust_123") ``` ### Connect App Api Key `POST /apps/{app_name}/connect/api-key` | Field | Type | Required | Description | |-------|------|----------|-------------| | api_key | string | yes | API key for the integration | | user_id | string | no | End-user ID for multi-tenant connections | | agent_id | int | no | Agent ID to grant this app after connecting (account scope) | | add_account | bool | no | Connect ANOTHER account of this app next to the existing one (a second Gmail inbox) instead of replacing it. Agents then pick the account per tool call. | | options | object | no | Provider options, such as region | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/apps/gmail/connect/api-key \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"api_key": "sk-...","user_id": "cust_123"}' ``` ```python result = client.apps.connect_api_key( "gemini", api_key="AIzaSy...", user_id="cust_123" ) print(result.status) # "connected" ``` **Response** `200 OK` ```json { "status": "connected", "app": "gmail" } ``` ### Connect App Complete After the user completes OAuth and is redirected back: `POST /apps/{app_name}/connect/complete` | Field | Type | Required | Description | |-------|------|----------|-------------| | connection_id | string | no | Connection to complete. Required for end-user connections (user_id set); ignored for account-level ones, where the connection comes from claim_ticket. | | claim_ticket | string | no | REQUIRED for account-level connections. Appended to your redirect_uri as `composio_claim` when the OAuth flow returns, so it reaches the browser that authorized. It is what proves the caller is the account that started the flow. without it a connect link could be forwarded and the recipient's provider account bound to whoever sent it. Not used for end-user connections, which the developer hands to their own end user by design. | | user_id | string | no | End-user ID for multi-tenant connections | | add_account | bool | no | Connect ANOTHER account of this app next to the existing one (a second Gmail inbox) instead of replacing it. Agents then pick the account per tool call. | | agent_id | int | no | Agent ID to grant this app after connecting (account scope) | | code | string | no | Authorization code for native OAuth apps such as Google Ads | | redirect_uri | string | no | Same redirect URI used to start a native OAuth connection | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/apps/gmail/connect/complete \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"connection_id": "conn_abc123","user_id": "cust_123"}' ``` ```python result = client.apps.connect_complete("gmail", "conn_abc123", user_id="cust_123") print(result.status) # "connected" ``` **Response** `200 OK` ```json { "status": "connected", "app": "gmail" } ``` ### Connect External Oauth `POST /apps/{app_name}/connect/external-oauth` | Field | Type | Required | Description | |-------|------|----------|-------------| | redirect_uri | string | yes | m8tes /apps URL on the configured frontend origin. The server adds the app callback marker; use this same URL at completion. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/apps/gmail/connect/external-oauth \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"redirect_uri": "..."}' ``` ```python result = client.apps.connect_external_oauth("linear:project_management", redirect_uri="https://www.m8tes.ai/apps") ``` ### Disconnect Slack Workspace `DELETE /apps/{app_name}/workspaces/{team_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Must be omitted: this resource is account-scoped, never per end-user. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/apps/gmail/workspaces/{team_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.apps.disconnect_workspace("slack:messaging", "T123") ``` ### Complete External Oauth `POST /apps/{app_name}/connect/external-oauth/complete` | Field | Type | Required | Description | |-------|------|----------|-------------| | redirect_uri | string | yes | m8tes /apps URL on the configured frontend origin. The server adds the app callback marker; use this same URL at completion. | | code | string | yes | Code | | state | string | yes | State | | agent_id | int | no | Agent Id | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/apps/gmail/connect/external-oauth/complete \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"redirect_uri": "...","code": "...","state": "..."}' ``` ```python result = client.apps.complete_external_oauth("linear:project_management", code="code", state="state", redirect_uri="https://www.m8tes.ai/apps") ``` **Response** `200 OK` ```json { "status": "connected", "app": "gmail" } ``` --- ## Built-In-Tools Discover the platform's built-in tools (memory, task history, task setup, feedback, and more). These are not passed in the `tools` array; the four configurable ones are toggled via the `enable_*` fields on agents, tasks, and runs. ### List Built In Tools `GET /built-in-tools` | Parameter | Type | Description | |-----------|------|-------------| | teammate_id | int | Resolve enabled state for this teammate's config | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/built-in-tools?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python tools = client.built_in_tools.list() tools = client.built_in_tools.list(teammate_id=1) ``` --- ## Judgments Advisory Jev judgments over supplied state or evidence. Platform-funded by default; connect a write-only TypeSafe key once for customer-funded account workloads. Neither mode debits your m8tes balance; customer funding is billed by TypeSafe. Invalid customer credentials never fall back to platform funding. Request-count limits apply to both modes. Results do not grant action permissions or certify source authenticity. Optional Idempotency-Key protects a request for 24 hours; successful results can be retrieved for 30 days. Evidence can reference scoped platform documents or stored tool results with content provenance. See [built-in tools](/docs/built-in-tools#jev-judgments). ### Create Judgment Choose `mode=decide` with `state` and `questions` (Choice, Score, Noul), or `mode=verify` with `claims` and `evidence`. Each claim has `id`, `text`, and `evidence_ids`. Evidence requires either `text` or `source` (`type: document|tool_result`, positive record `id`), not both. References resolve stored content within the same account/end-user scope; tool-result IDs identify persisted conversation messages containing tool-result blocks. Optional `user_id` sets the end-user scope; `run_id` attributes a same-scope run. Inputs are limited to 32 questions/claims, 16,384 characters per source, and 128 KiB after source resolution. Oversized evidence is rejected. Results include typed `answers`, `model`, `usage`, `latency_ms`, and estimated provider `cost_usd` and `funding_source` (`platform` or `customer`). m8tes does not debit your balance; customer-funded requests are billed by TypeSafe. Verification includes `rubric_version`, claim/evidence coverage, and content provenance; `independently_authenticated` is always false. Missing evidence produces `insufficient_evidence`; if no provider call is needed, `model` is `not_called`. Pass the optional `Idempotency-Key` header (SDK `idempotency_key`) to protect an exact request for 24 hours. Success replays return the same ID and original cost without another provider charge. Conflicting, pending, or failed attempts return 409. Metadata-only scopes do not retain answers and return 410 on replay. SDK automatic POST retries require a key. `POST /judgments` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/judgments \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"mode": "verify","claims": [{"id": "c1","text": "Tests passed","evidence_ids": ["e1"]}],"evidence": [{"id": "e1","text": "pytest: 12 passed"}]}' ``` ```python result = client.judgments.create( mode="verify", claims=[{"id": "c1", "text": "Tests passed", "evidence_ids": ["e1"]}], evidence=[{"id": "e1", "text": "pytest: 12 passed"}], ) ``` ### Get Connection Read account-level TypeSafe metadata only: `connected`, `enabled`, `funding_source`, `model`, and nullable `updated_at`. The key is never returned. `connected` means a key is stored, not validated by the provider. No `user_id` is accepted: one connection covers all end-user and agent workloads. `GET /judgments/connection` ```bash curl \ https://api.m8tes.ai/api/v2/judgments/connection \ -H "Authorization: Bearer m8_your_key_here" ``` ```python connection = client.judgments.connection() ``` ### Get Judgment Retrieve a saved successful judgment for 30 days without another provider call. Use the original `user_id`; missing or foreign IDs return 404. Pending/failed attempts return 409. Expired results and metadata-only retention return 410 `result_not_retained`. Returned `cost_usd` is the original estimate, not another charge. Settled known-cost metadata is retained for 90 days; unresolved or unknown-cost reservations remain for reconciliation. `GET /judgments/{judgment_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/judgments/{judgment_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python result = client.judgments.get("jv_example") ``` ### Put Connection Configure once per account from a trusted server, or replace the key to rotate it. Read `api_key` from a server environment variable or secret manager; never pass it in prompts or agent tool arguments. It is write-only and encrypted at rest. No paid validation occurs when storing the key. Invalid customer credentials never fall back to platform funding. The service kill switch and request-count limits still apply; platform dollar caps do not apply to customer funding. Returns the same metadata as GET. `PUT /judgments/connection` | Field | Type | Required | Description | |-------|------|----------|-------------| | api_key | string | yes | Api Key | ```bash curl \ -X PUT \ https://api.m8tes.ai/api/v2/judgments/connection \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"api_key": "..."}' ``` ```python connection = client.judgments.configure(api_key=os.environ["TYPESAFE_API_KEY"]) ``` ### Delete Connection Remove the account's TypeSafe key. Returns HTTP 204 without a body. Future judgments return to platform funding if available; deletion does not enable a service disabled by the platform. `DELETE /judgments/connection` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/judgments/connection \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.judgments.disconnect() ``` --- ## Mcp-Servers Register your own REST, remote MCP, or Python script tools. `kind=script` takes `script_source` (write-only; reads return `script_sha256`) and cannot take `user_id`; strict API accounts can still create one. See the [Custom tools](/docs/custom-tools) guide. ### Create Mcp Server `kind` is `rest_api` (default; typed `tool_defs` with method/path), `mcp_http`/`mcp_sse` (remote MCP; empty `tool_defs`), or `script` (Python `script_source`; `tool_defs` are name-only; `user_id` is forbidden). `script_source` is write-only; the response carries `script_sha256`. `POST /mcp-servers` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | Display name. | | url | string | yes | HTTPS endpoint URL to receive deliveries. | | kind | string | no | `rest_api` (typed endpoints in `tool_defs`), `mcp_http`/`mcp_sse` (remote MCP; empty `tool_defs`), or `script` (Python; `script_source` required, `user_id` forbidden). | | auth_type | string | no | Auth Type | | auth_config | object | no | Auth Config | | secret | string | no | Secret | | tool_defs | any[] | no | Tool Defs | | description | string | no | Description | | user_id | string | no | User Id | | auto_approve | bool | no | Auto Approve | | script_source | string | no | Python source for `kind=script`. Write-only; never returned. Must define `def (**args)` for each tool_def name. Injected names: `http`, `json` only. | | script_allowlist | string[] | no | Extra https origins a `kind=script` tool may call (max 8). The primary origin is `url` (canonical https origin, no path). | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/mcp-servers \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "acme billing","url": "https://api.acme.com/v1","auth_type": "bearer","secret": "sk-...","tool_defs": [{"method": "GET","name": "get_invoice","path": "/invoices/{id}"}]}' ``` ### Create Mcp Server `kind` is `rest_api` (default; typed `tool_defs` with method/path), `mcp_http`/`mcp_sse` (remote MCP; empty `tool_defs`), or `script` (Python `script_source`; `tool_defs` are name-only; `user_id` is forbidden). `script_source` is write-only; the response carries `script_sha256`. `POST /mcp-servers` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | Display name. | | url | string | yes | HTTPS endpoint URL to receive deliveries. | | kind | string | no | `rest_api` (typed endpoints in `tool_defs`), `mcp_http`/`mcp_sse` (remote MCP; empty `tool_defs`), or `script` (Python; `script_source` required, `user_id` forbidden). | | auth_type | string | no | Auth Type | | auth_config | object | no | Auth Config | | secret | string | no | Secret | | tool_defs | any[] | no | Tool Defs | | description | string | no | Description | | user_id | string | no | User Id | | auto_approve | bool | no | Auto Approve | | script_source | string | no | Python source for `kind=script`. Write-only; never returned. Must define `def (**args)` for each tool_def name. Injected names: `http`, `json` only. | | script_allowlist | string[] | no | Extra https origins a `kind=script` tool may call (max 8). The primary origin is `url` (canonical https origin, no path). | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/mcp-servers \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "acme billing","url": "https://api.acme.com/v1","auth_type": "bearer","secret": "sk-...","tool_defs": [{"method": "GET","name": "get_invoice","path": "/invoices/{id}"}]}' ``` ### List Mcp Servers `GET /mcp-servers` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/mcp-servers?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ### List Mcp Servers `GET /mcp-servers` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/mcp-servers?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ### Get Mcp Server `GET /mcp-servers/{server_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/mcp-servers/{server_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ### Update Mcp Server `PATCH /mcp-servers/{server_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Display name. | | url | string | no | HTTPS endpoint URL to receive deliveries. | | auth_type | string | no | Auth Type | | auth_config | object | no | Auth Config | | secret | string | no | Secret | | tool_defs | any[] | no | Tool Defs | | description | string | no | Description | | status | string | no | Status | | auto_approve | bool | no | Auto Approve | | script_source | string | no | Replace the Python source (`kind=script` only). Write-only. Clears auto_approve. | | script_allowlist | string[] | no | Replace extra https origins (`kind=script` only). Clears auto_approve. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/mcp-servers/{server_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support agent"}' ``` ### Delete Mcp Server `DELETE /mcp-servers/{server_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/mcp-servers/{server_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ### Approve Mcp Server `POST /mcp-servers/{server_id}/approve` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/mcp-servers/{server_id}/approve \ -H "Authorization: Bearer m8_your_key_here" ``` ```python server = client.mcp_servers.approve(server.id) ``` ### Complete Mcp Server Oauth `POST /mcp-servers/{server_id}/oauth/complete` | Field | Type | Required | Description | |-------|------|----------|-------------| | code | string | yes | Code | | state | string | yes | State | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/mcp-servers/{server_id}/oauth/complete \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"code": "...","state": "..."}' ``` ```python server = client.mcp_servers.complete_oauth(server.id, code="code", state="state") ``` ### Start Mcp Server Oauth `POST /mcp-servers/{server_id}/oauth/start` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/mcp-servers/{server_id}/oauth/start \ -H "Authorization: Bearer m8_your_key_here" ``` ```python result = client.mcp_servers.start_oauth(server.id) ``` --- ## Skills Reusable SKILL.md playbooks agents load on demand. Create account- or agent-scoped skills; see the [Custom Skills guide](/docs/custom-skills). Force a skill from chat with `/slug` or pass `skill` on create/reply. List picker entries with `GET /skills/invokable` (platform commands like `make-skill` / `own-task` / `feedback`, your custom skills and, with `teammate_id` of an agent bound to a repository, the skills that repository commits — never the baked snapshot catalog). ### Create Skill `POST /skills` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | Display name. | | description | string | yes | Description | | body | string | yes | Body | | scope | string | no | Scope | | teammate_id | int | no | ID of the agent to use. | | user_id | string | no | User Id | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/skills \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "acme refund playbook","description": "How to process an Acme refund end-to-end.","body": "# Steps\n1. Pull the order in Stripe\n2. ...","scope": "account"}' ``` ### Create Skill `POST /skills` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | Display name. | | description | string | yes | Description | | body | string | yes | Body | | scope | string | no | Scope | | teammate_id | int | no | ID of the agent to use. | | user_id | string | no | User Id | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/skills \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "acme refund playbook","description": "How to process an Acme refund end-to-end.","body": "# Steps\n1. Pull the order in Stripe\n2. ...","scope": "account"}' ``` ### List Skills `GET /skills` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/skills?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ### List Skills `GET /skills` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/skills?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ### List Invokable Skills `GET /skills/invokable` | Parameter | Type | Description | |-----------|------|-------------| | teammate_id | int | Filter by agent | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/skills/invokable?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ### List Invokable Skills `GET /skills/invokable` | Parameter | Type | Description | |-----------|------|-------------| | teammate_id | int | Filter by agent | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/skills/invokable?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ### Get Skill `GET /skills/{skill_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/skills/{skill_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ### Update Skill `PATCH /skills/{skill_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Display name. | | description | string | no | Description | | body | string | no | Body | | status | string | no | Status | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/skills/{skill_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support agent"}' ``` ### Delete Skill `DELETE /skills/{skill_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/skills/{skill_id} \ -H "Authorization: Bearer m8_your_key_here" ``` --- ## Bridges Per-account BlueBubbles bridges that connect an agent to iMessage. Register a bridge, then enable inbound iMessage on an agent with `bridge_id`. ### Create Bridge `POST /bridges` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Display name. | | server_url | string | yes | https URL of the BlueBubbles server | | password | string | yes | BlueBubbles API password (stored encrypted, never returned) | | owner_handle | string | no | Your own iMessage handle (phone or email). Authorizes you to text the Lead mate (your inbound-default agent) right away, without editing its allowlist. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/bridges \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"server_url": "https://bluebubbles.example.com","password": "your-password"}' ``` ### List Bridges `GET /bridges` ```bash curl \ https://api.m8tes.ai/api/v2/bridges \ -H "Authorization: Bearer m8_your_key_here" ``` ### Provision Bridge `POST /bridges/provision` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/bridges/provision \ -H "Authorization: Bearer m8_your_key_here" ``` ### Provision Blooio `POST /bridges/provision-blooio` | Field | Type | Required | Description | |-------|------|----------|-------------| | number | string | yes | The dedicated Blooio iMessage number (E.164) to route to this account. | | api_key | string | no | Optional BYO Blooio API key (stored encrypted); omit to use the platform key. | | user_id | string | no | Optional end-user to bind this dedicated line to (multi-tenant isolation). | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/bridges/provision-blooio \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"number": "+15551234567"}' ``` ### Get Bridge `GET /bridges/{bridge_id}` ```bash curl \ https://api.m8tes.ai/api/v2/bridges/{bridge_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ### Update Bridge `PATCH /bridges/{bridge_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Display name. | | server_url | string | no | Server Url | | password | string | no | Password | | status | string | no | Status | | owner_handle | string | no | Owner Handle | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/bridges/{bridge_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support agent"}' ``` ### Delete Bridge `DELETE /bridges/{bridge_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/bridges/{bridge_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ### Regenerate Bridge Link Code `POST /bridges/{bridge_id}/link-code` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/bridges/{bridge_id}/link-code \ -H "Authorization: Bearer m8_your_key_here" ``` ### Rotate Bridge Secret `POST /bridges/{bridge_id}/rotate-secret` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/bridges/{bridge_id}/rotate-secret \ -H "Authorization: Bearer m8_your_key_here" ``` ### Test Bridge Connection `POST /bridges/{bridge_id}/test` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/bridges/{bridge_id}/test \ -H "Authorization: Bearer m8_your_key_here" ``` ### List Bridge Handles `GET /bridges/{bridge_id}/handles` ```bash curl \ https://api.m8tes.ai/api/v2/bridges/{bridge_id}/handles \ -H "Authorization: Bearer m8_your_key_here" ``` ### Delete Bridge Handle `DELETE /bridges/{bridge_id}/handles/{handle_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/bridges/{bridge_id}/handles/{handle_id} \ -H "Authorization: Bearer m8_your_key_here" ``` --- ## Channels Slack and GitHub channel identity for this account. With no identity the shared `@m8tes` Slack app / m8tes GitHub App is used; `upsert_identity` stores your own Slack app or GitHub App credentials (bot/App name and avatar stay in Slack/GitHub dashboards). `channel=github` requires `github_app_id`, `github_app_slug`, and `github_private_key`. `install_links` mints Add-to-Slack and GitHub App install URLs; it 503s if Slack is unavailable even when GitHub is configured. ### List Channels `GET /channels` ```bash curl \ https://api.m8tes.ai/api/v2/channels \ -H "Authorization: Bearer m8_your_key_here" ``` ```python page = client.channels.list() ``` ### Get Install Links `GET /channels/install-links` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/channels/install-links?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python links = client.channels.install_links(user_id="cust_123") ``` ### Upsert Identity `PUT /channels/identities` | Field | Type | Required | Description | |-------|------|----------|-------------| | channel | string | yes | slack or github. Email custom domains are a later slice. | | client_id | string | yes | Client Id | | client_secret | string | yes | Client Secret | | signing_secret | string | yes | Signing Secret | | github_app_id | string | no | Required when channel is github. | | github_app_slug | string | no | Required when channel is github. | | github_private_key | string | no | Required when channel is github. Never returned. | ```bash curl \ -X PUT \ https://api.m8tes.ai/api/v2/channels/identities \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"channel": "slack","client_id": "...","client_secret": "...","signing_secret": "..."}' ``` ```python channel = client.channels.upsert_identity( channel="slack", client_id="...", client_secret="...", signing_secret="...", ) ``` --- ## Github-App Coding-agent GitHub install. Account-scoped (not per `user_id`). m8tes GitHub App: open `install_url`, then `claim(ticket)` if the redirect returns one. Your GitHub App: open `setup_url`, then `complete_setup(ticket)` stores credentials and returns the install URL. Same claim flow after that. `clear_identity` reverts after disconnect. Per-agent repos: `/agents/{id}/repos`. See [Coding Agents](/docs/coding-agents). ### Github App Disconnect `DELETE /github-app` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/github-app \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.github_app.disconnect() ``` ### Github App Claim `POST /github-app/claim` | Field | Type | Required | Description | |-------|------|----------|-------------| | ticket | string | yes | Ticket | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/github-app/claim \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"ticket": "..."}' ``` ```python client.github_app.claim(ticket="…") ``` ### Github App Complete Setup `POST /github-app/complete-setup` | Field | Type | Required | Description | |-------|------|----------|-------------| | ticket | string | yes | Ticket | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/github-app/complete-setup \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"ticket": "..."}' ``` ```python client.github_app.complete_setup(ticket="…") ``` ### Github App Install Url `GET /github-app/install-url` ```bash curl \ https://api.m8tes.ai/api/v2/github-app/install-url \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.github_app.install_url() ``` ### Github App Repos `GET /github-app/repos` ```bash curl \ https://api.m8tes.ai/api/v2/github-app/repos \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.github_app.list_repos() ``` ### Github App Setup Url `GET /github-app/setup-url` | Parameter | Type | Description | |-----------|------|-------------| | cancel_epoch | int | users.github_manifest_epoch from status at click time — rejects if Use m8tes App cleared since | | org | string | | | name | string | | ```bash curl \ https://api.m8tes.ai/api/v2/github-app/setup-url?cancel_epoch=... \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.github_app.setup_url(cancel_epoch=status.manifest_epoch, org="acme") ``` ### Github App Status `GET /github-app/status` ```bash curl \ https://api.m8tes.ai/api/v2/github-app/status \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.github_app.status() ``` ### Github App Clear Identity `DELETE /github-app/identity` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/github-app/identity \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.github_app.clear_identity() ``` --- ## Memories Pre-populate or manage end-user memories. Memories are injected into the agent's context during execution to personalize responses. ### Create Memory `POST /memories` | Field | Type | Required | Description | |-------|------|----------|-------------| | user_id | string | no | End-user ID this memory belongs to. Omit for an account-level memory (seen by runs that have no user_id). | | content | string | yes | The text content to store. | | audience | string | no | Whether this memory is about the end-user themselves or about their business. Optional; omit when you cannot tell. Both are read by every agent today. This records the distinction for future visibility rules rather than changing what is injected now. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/memories \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"user_id": "customer_123","content": "Prefers email over Slack"}' ``` ```python memory = client.memories.create(user_id="customer_123", content="Prefers email over Slack") ``` **Response** `201 Created` ```json { "id": 7, "user_id": "customer_123", "content": "Prefers email over Slack", "source": "api", "created_at": "2026-01-15T10:00:00Z" } ``` ### List Memories Pass `user_id` to list one end-user's memories; omit it for account-level memories. The two scopes never mix. `GET /memories` | Parameter | Type | Description | |-----------|------|-------------| | query | string | Optional keyword filter: case-insensitive substring match on content. | | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/memories?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python memories = client.memories.list(user_id="customer_123", query="email") ``` ### Update Memory `PATCH /memories/{memory_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | content | string | no | The text content to store. | | audience | string | no | Reclassify this memory as personal or company. Omitted leaves the existing classification alone. Send it explicitly to correct one. Classification is one-way: you can change personal to company or vice versa, but not back to unclassified, which means 'never classified' rather than 'we decided we cannot tell'. Delete and re-create if you truly need that. Ignored for teammate-scoped memories, whose audience is the teammate. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/memories/7 \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"content": "prefers a concise Monday summary, no charts"}' ``` ```python client.memories.update(7, content="Prefers Slack now", user_id="customer_123") ``` **Response** `200 OK` ```json { "id": 7, "user_id": "customer_123", "content": "Prefers email over Slack", "source": "api", "created_at": "2026-01-15T10:00:00Z" } ``` ### Delete Memory Matches exactly the scope you pass: an end-user memory is only reachable with its `user_id`, an account-level memory only without one. `DELETE /memories/{memory_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/memories/7 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.memories.delete(7, user_id="customer_123") ``` --- ## Documents Manage persistent company- or agent-scoped context. Agents author document content; API clients can list, read, rename, summarize, and delete it. ### List Documents `GET /documents` | Parameter | Type | Description | |-----------|------|-------------| | scope | string | | | agent_id | int | | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/documents?user_id=customer_123&scope=... \ -H "Authorization: Bearer m8_your_key_here" ``` ```python documents = client.documents.list(scope="company") ``` ### Get Document `GET /documents/{document_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/documents/{document_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python document = client.documents.get(7) ``` ### Update Document `PATCH /documents/{document_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Display name. | | summary | string | no | Summary | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/documents/{document_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support agent"}' ``` ```python document = client.documents.update(7, name="brand-guide") ``` ### Delete Document `DELETE /documents/{document_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/documents/{document_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.documents.delete(7) ``` --- ## Artifacts Durable, shareable copies of what a run produced. Promote a run file (or its `latest-report.md`) into an artifact that outlives the sandbox, then mint a public link the way you would for a run. ### Create Artifact `POST /artifacts` | Field | Type | Required | Description | |-------|------|----------|-------------| | run_id | int | yes | Run Id | | filename | string | yes | A basename from GET /runs/{id}/files, e.g. latest-report.md | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/artifacts \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"run_id": 1,"filename": "..."}' ``` ```python artifact = client.artifacts.create(run_id=42, filename="latest-report.md") ``` ### List Artifacts `GET /artifacts` | Parameter | Type | Description | |-----------|------|-------------| | run_id | int | | | agent_id | int | | | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: ID of last item from previous page | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/artifacts?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python artifacts = client.artifacts.list(run_id=42) ``` ### Get Artifact `GET /artifacts/{artifact_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/artifacts/{artifact_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python artifact = client.artifacts.get(7) ``` ### Delete Artifact `DELETE /artifacts/{artifact_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/artifacts/{artifact_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.artifacts.delete(7) ``` ### Share Artifact `POST /artifacts/{artifact_id}/share` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/artifacts/{artifact_id}/share \ -H "Authorization: Bearer m8_your_key_here" ``` ```python share = client.artifacts.share(7) # share.share_url ``` ### Get Artifact Content `GET /artifacts/{artifact_id}/content` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/artifacts/{artifact_id}/content \ -H "Authorization: Bearer m8_your_key_here" ``` ```python data = client.artifacts.download(7) # bytes ``` ### Unshare Artifact `DELETE /artifacts/{artifact_id}/share` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/artifacts/{artifact_id}/share \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.artifacts.unshare(7) ``` --- ## Feedback Send product feedback to the m8tes team, optionally attaching a run. Same chokepoint as the `/feedback` slash command and the MCP `submit_feedback` tool. Pass `user_id` when attaching an end-user-scoped run. ### Submit feedback `POST /feedback` | Field | Type | Required | Description | |-------|------|----------|-------------| | message | string | yes | Feedback text sent to the m8tes team. | | run_id | int | no | Optional run to attach so we can inspect that execution. | | title | string | no | Optional short title. Defaults to the first line of message. | | user_id | string | no | Your end-user ID for data isolation. When set, an attached run must belong to this end-user. Required when the account has require_end_user_id enabled. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/feedback \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"message": "This run stalled on the Google Ads connect step.","run_id": 42,"user_id": "customer_123"}' ``` **Response** `201 Created` ```json { "id": 9, "title": "This run stalled on the Google Ads connect step.", "run_id": 42, "created_at": "2026-09-27T12:00:00Z" } ``` --- ## Groups Flat mate groups for organizing agents (Team axis). Assign via `agents.create(group_id=...)` or `agents.update(group_id=...)`. Not human Organizations — those stay under [Teams](#teams). `POST /groups/{id}/share` bulk-sets mate visibility (selection set); the group organizes which mates flip — it is not a group ACL. ### Create Group `POST /groups` | Field | Type | Required | Description | |-------|------|----------|-------------| | parent_id | int | no | Parent Team; null creates a root. | | name | string | yes | Group name. | | user_id | string | no | End-user scope for this group. Omit for account-level. | | display_order | int | no | Optional list position (lower sorts first). | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/groups \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support agent"}' ``` ```python group = client.groups.create(name="Growth") ``` ### List Groups `GET /groups` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/groups?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python groups = client.groups.list() ``` ### Get Group `GET /groups/{group_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/groups/{group_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python group = client.groups.get(1) ``` ### Update Group `PATCH /groups/{group_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | parent_id | int | no | Omit to keep parent; null moves to root. | | name | string | no | Display name. | | display_order | int | no | Display Order | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/groups/{group_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"parent_id": 1}' ``` ```python client.groups.update(1, name="Revenue") ``` ### Delete Group `DELETE /groups/{group_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/groups/{group_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.groups.delete(1) ``` ### Accept Group Invite `POST /groups/invites/accept` | Field | Type | Required | Description | |-------|------|----------|-------------| | token | string | yes | Token | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/groups/invites/accept \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"token": "..."}' ``` ```python client.groups.accept_invite(token) ``` ### Create Group Invite `POST /groups/{group_id}/invites` | Field | Type | Required | Description | |-------|------|----------|-------------| | role | string | no | Role | | email | string | yes | Email address. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/groups/{group_id}/invites \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com"}' ``` ```python client.groups.invite(1, email="alex@example.com") ``` ### Share Group `POST /groups/{group_id}/share` | Field | Type | Required | Description | |-------|------|----------|-------------| | visibility | string | yes | Target mate visibility. The group organizes which mates flip; organization visibility is what org members can see — not a group ACL. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/groups/{group_id}/share \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"visibility": "personal"}' ``` ```python result = client.groups.share(1, visibility="organization") ``` ### Preview Group Invite `GET /groups/invites/{token}` ```bash curl \ https://api.m8tes.ai/api/v2/groups/invites/{token} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.groups.preview_invite(token) ``` ### List Group Invites `GET /groups/{group_id}/invites` ```bash curl \ https://api.m8tes.ai/api/v2/groups/{group_id}/invites \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.groups.invites(1) ``` ### List Group Members `GET /groups/{group_id}/members` ```bash curl \ https://api.m8tes.ai/api/v2/groups/{group_id}/members \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.groups.members(1) ``` ### Cancel Group Invite `DELETE /groups/invites/{invite_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/groups/invites/{invite_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.groups.cancel_invite(invite_id) ``` ### Update Group Member `PATCH /groups/{group_id}/members/{member_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | role | string | yes | Role | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/groups/{group_id}/members/{member_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"role": "viewer"}' ``` ```python client.groups.update_member(1, member_id, role="runner") ``` ### Remove Group Member `DELETE /groups/{group_id}/members/{member_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/groups/{group_id}/members/{member_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.groups.remove_member(1, member_id) ``` --- ## Permissions Pre-configure tool allow-lists for your end-users. Tools added here are auto-approved when runs use `approval` mode. ### Create Permission Idempotent: re-creating the same permission returns the existing record. `POST /permissions` | Field | Type | Required | Description | |-------|------|----------|-------------| | user_id | string | no | End-user ID. Omit to target the account-level scope. | | tool | string | yes | Tool name to allow, e.g. 'gmail' | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/permissions \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"user_id": "customer_123","tool": "gmail"}' ``` ```python policy = client.permissions.create(user_id="customer_123", tool="gmail") ``` **Response** `201 Created` ```json { "id": 3, "user_id": "customer_123", "tool_name": "gmail", "created_at": "2026-01-15T10:00:00Z" } ``` ### List Permissions `GET /permissions` | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/permissions?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python policies = client.permissions.list(user_id="customer_123") ``` ### Delete Permission `DELETE /permissions/{permission_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/permissions/3 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.permissions.delete(3, user_id="customer_123") ``` --- ## Users Manage end-user profiles. Profiles are auto-created when `user_id` is passed to any endpoint, or created explicitly with profile data. ### Create End User `POST /users` | Field | Type | Required | Description | |-------|------|----------|-------------| | user_id | string | yes | Your end-user identifier | | name | string | no | Display name. | | email | string | no | Email address. | | company | string | no | Company name. | | metadata | object | no | Arbitrary key-value data | | run_limit | int | no | Override: this end-user's completed-runs cap per billing period (0 = blocked entirely; null = inherit the account-wide default) | | cost_limit_cents | int | no | Override: this end-user's metered-cost cap in US cents (null = inherit) | | rate_per_minute | int | no | Override: this end-user's run-starts-per-minute rate limit (null = inherit) | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/users \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"user_id": "cust_123","name": "Alice Smith","email": "alice@acme.com"}' ``` ```python user = client.users.create( user_id="cust_123", name="Alice Smith", email="alice@acme.com" ) ``` **Response** `201 Created` ```json { "id": 1, "user_id": "cust_123", "name": "Alice Smith", "email": "alice@acme.com", "company": "Acme Corp", "created_at": "2026-01-15T10:00:00Z" } ``` ### List End Users `GET /users` | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | ```bash curl \ https://api.m8tes.ai/api/v2/users \ -H "Authorization: Bearer m8_your_key_here" ``` ```python page = client.users.list() for user in page.data: print(user.user_id, user.name) ``` ### Get End User `GET /users/{user_id}` ```bash curl \ https://api.m8tes.ai/api/v2/users/{user_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python user = client.users.get("cust_123") ``` **Response** `200 OK` ```json { "id": 1, "user_id": "cust_123", "name": "Alice Smith", "email": "alice@acme.com", "company": "Acme Corp", "created_at": "2026-01-15T10:00:00Z" } ``` ### Update End User `PATCH /users/{user_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Display name. | | email | string | no | Email address. | | company | string | no | Company name. | | metadata | object | no | Arbitrary key-value data | | run_limit | int | no | Override: this end-user's completed-runs cap per billing period (0 = blocked entirely; null = inherit the account-wide default) | | cost_limit_cents | int | no | Override: this end-user's metered-cost cap in US cents (null = inherit) | | rate_per_minute | int | no | Override: this end-user's run-starts-per-minute rate limit (null = inherit) | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/users/{user_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "Alice Smith-Jones","company": "Acme Corp"}' ``` ```python client.users.update("cust_123", name="Alice Jones", company="Acme Corp") ``` **Response** `200 OK` ```json { "id": 1, "user_id": "cust_123", "name": "Alice Smith", "email": "alice@acme.com", "company": "Acme Corp", "created_at": "2026-01-15T10:00:00Z" } ``` ### Delete End User `DELETE /users/{user_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/users/{user_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.users.delete("cust_123") ``` --- ## Teams Organizations the account belongs to, their seats, and email invites. Invite and accept are gated by the Teams beta flag; listing, revoking an invite, and removing a member always work for an existing organization. ### List Teams `GET /teams` ```bash curl \ https://api.m8tes.ai/api/v2/teams \ -H "Authorization: Bearer m8_your_key_here" ``` ```python teams = client.teams.list() ``` ### Accept Team Invite `POST /teams/invites/accept` | Field | Type | Required | Description | |-------|------|----------|-------------| | token | string | yes | Token | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/teams/invites/accept \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"token": "..."}' ``` ```python membership = client.teams.accept_invite("inv_...") ``` ### Create Team Invite `POST /teams/{org_id}/invites` | Field | Type | Required | Description | |-------|------|----------|-------------| | email | string | yes | Email address. | | role | string | no | Role | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/teams/{org_id}/invites \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com"}' ``` ```python invite = client.teams.invite(org_id, email="colleague@acme.com", role="member") ``` ### Preview Team Invite `GET /teams/invites/{token}` ```bash curl \ https://api.m8tes.ai/api/v2/teams/invites/{token} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python preview = client.teams.invite_preview("inv_...") ``` ### Revoke Team Invite `DELETE /teams/invites/{invite_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/teams/invites/{invite_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.teams.revoke_invite(invite_id) ``` ### Remove Team Member `DELETE /teams/{org_id}/members/{member_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/teams/{org_id}/members/{member_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.teams.remove_member(org_id, member_id) ``` --- ## Settings Account-level configuration: data retention and per-end-user sub-caps. ### Get Settings `GET /settings` ```bash curl \ https://api.m8tes.ai/api/v2/settings \ -H "Authorization: Bearer m8_your_key_here" ``` ```python settings = client.settings.get() ``` **Response** `200 OK` ```json { "retention_mode": "standard" } ``` ### Update Settings `PATCH /settings` | Field | Type | Required | Description | |-------|------|----------|-------------| | per_end_user_run_limit | int | no | Cap each end-user's (user_id) completed runs per billing period so one end-user can't drain the account budget. Send null to remove the cap; omit to leave unchanged. | | per_end_user_cost_limit_cents | int | no | Cap each end-user's metered run cost (US cents) per billing period. Send null to remove the cap; omit to leave unchanged. | | per_end_user_rate_per_minute | int | no | Rate-limit each end-user to this many agent turns per minute; run starts AND replies (429 END_USER_RATE_LIMITED past it). Send null to turn off; omit to leave unchanged. | | retention_mode | string | no | Data retention. 'standard' stores conversation content; 'metadata_only' (zero data retention) never persists message content, tool I/O, or generated reports; only metadata (token/cost metrics, tool names). Omit to leave unchanged. | | require_end_user_id | bool | no | Strict multi-tenant mode: reject requests that would land data in the account-level scope because user_id was omitted (422) instead of silently assuming the global account scope. Enforced on agent, task, run, skill, and custom-MCP-server creation; on every memory operation; and on LISTING agents, tasks, runs, skills, custom MCP servers, memories and permissions; an unscoped list is how a forgotten user_id returns one end-user another's rows. Catalogue reads (apps, built-in tools), app connections, and replies/retries of pre-existing unscoped runs are exempt. Recommended for multi-tenant integrations. Omit to leave unchanged. | | approval_email_enabled | bool | no | Email you when a run has waited a few minutes for your approval or answer. false stops those emails; the run waits in the dashboard and runs.permissions() until someone answers. Omit to leave unchanged. | | approval_slack_enabled | bool | no | Slack DM you about a run waiting on your approval (needs Slack connected). false stops the DM; a run started in Slack still asks in its own thread. Omit to leave unchanged. | | result_email_enabled | bool | no | Email you the result of a run you started and have not viewed 5 minutes after it finished. false stops those emails; the result waits in the dashboard. Omit to leave unchanged. | | result_slack_enabled | bool | no | Slack DM you that result instead, when Slack is connected. false stops the DM; the email is used when it is on. Omit to leave unchanged. | | preferred_approval_channel | string | no | Try this place first when a run waits on you (email, slack, or imessage). The other places still follow if it does not deliver, unless their own switch is off. Omit or null leaves the current value unchanged. | | result_notify_channel | string | no | Which place the preferences page shows for a finished run (email or slack). Delivery still follows the result email and Slack switches. Omit or null leaves the current value unchanged. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/settings \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"retention_mode": "metadata_only"}' ``` ```python client.settings.update(retention_mode="metadata_only") ``` **Response** `200 OK` ```json { "retention_mode": "standard" } ``` --- ## Keys ### Create Named Key `POST /keys` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | A label, e.g. 'production'. | | expires_in_days | int | no | Optional expiry in days; omit for no expiry. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/keys \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support agent"}' ``` ```python key = client.keys.create(name="production") ``` ### Get Key Info `GET /keys` ```bash curl \ https://api.m8tes.ai/api/v2/keys \ -H "Authorization: Bearer m8_your_key_here" ``` ```python info = client.keys.info() ``` ### Revoke Key `DELETE /keys` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/keys \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.keys.revoke() ``` ### Rotate Key `POST /keys/rotate` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/keys/rotate \ -H "Authorization: Bearer m8_your_key_here" ``` ```python key = client.keys.rotate() ``` ### List Named Keys `GET /keys/all` ```bash curl \ https://api.m8tes.ai/api/v2/keys/all \ -H "Authorization: Bearer m8_your_key_here" ``` ```python keys = client.keys.list() ``` ### Revoke Named Key `DELETE /keys/{key_id}` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/keys/{key_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.keys.revoke(key_id) ``` ### Rotate Named Key `POST /keys/{key_id}/rotate` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/keys/{key_id}/rotate \ -H "Authorization: Bearer m8_your_key_here" ``` ```python key = client.keys.rotate(key_id) ``` --- ## Usage Inspect the current account plan, run usage, cost usage, and billing period. ### Get Usage `GET /usage` ```bash curl \ https://api.m8tes.ai/api/v2/usage \ -H "Authorization: Bearer m8_your_key_here" ``` ```python usage = client.auth.get_usage() print(usage.plan, usage.runs_used, usage.cost_used) ``` `client.billing.usage()` is an alias for `client.auth.get_usage()`. **Response** `200 OK` ```json { "plan": "trial", "runs_used": 2, "runs_limit": 50, "cost_used": "0.12", "cost_limit": "20.00", "period_end": "2026-03-27T10:00:00Z", "free_path_available": true, "overage_enabled": false, "overage_used_cents": 0, "overage_cap_cents": 0, "overage_rate_cents": 200, "trial_ends_at": "2026-04-01T10:00:00Z", "unlimited_runs": false, "concurrent_runs": 1, "concurrent_runs_limit": 10 } ``` ### List End User Usage `GET /usage/end-users` | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/usage/end-users?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python page = client.users.usage() for row in page.data: print(row.user_id, row.runs_used, row.total_tokens) ``` ### Get Usage Timeseries `GET /usage/timeseries` | Parameter | Type | Description | |-----------|------|-------------| | start_date | string | First UTC day (default: 29 days before end_date) | | end_date | string | Last UTC day, inclusive (default: today) | | teammate_id | int | Filter to one teammate | | surface | string | Filter by the run's stamped billing surface: "api" (end-user-scoped / embedding work) or "platform" (first-party work). This is the surface a run was CREATED on, not the meter it finally settled on. A run's settlement can differ (own-subscription turns, plan finalisation). Omit for every surface. | | settled_meter | string | Filter by where the run actually settled: wallet (prepaid ledger), plan (subscription), released (non-billable), or own_sub (user's own model subscription). This is Run.settled_meter, not billing_surface. The API billing page passes wallet so the chart cannot show spend the prepaid ledger never saw. Cost is still the usage estimate, not SUM(ledger debits). Omit for every settlement. | | group_by | string | Add per-model slices to each bucket ("model") | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/usage/timeseries?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python series = client.billing.usage_timeseries() for b in series.buckets: print(b.date, b.total_tokens, b.cost_usd) ``` **Response** `200 OK` ```json { "start_date": "2026-06-14", "end_date": "2026-07-13", "buckets": [ { "cache_creation_tokens": 12000, "cache_read_tokens": 91000, "cost_usd": "3.41", "date": "2026-07-13", "input_tokens": 182000, "output_tokens": 24500, "total_tokens": 309500 } ], "totals": { "cache_creation_tokens": 12000, "cache_read_tokens": 91000, "cost_usd": "3.41", "input_tokens": 182000, "output_tokens": 24500, "total_tokens": 309500 } } ``` --- ## Billing Inspect the public Hobby and paid-plan catalog, manage opt-in usage overage for eligible team plans (per-run charges past the included runs, capped at a monthly ceiling you set), and top up or read a prepaid token balance (a micro-USD balance plus its ledger). ### Activate Hobby Plan `POST /billing/activate-free` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/billing/activate-free \ -H "Authorization: Bearer m8_your_key_here" ``` ```python usage = client.billing.activate_free() ``` **Response** `200 OK` ```json { "plan": "trial", "runs_used": 2, "runs_limit": 50, "cost_used": "0.12", "cost_limit": "20.00", "period_end": "2026-03-27T10:00:00Z", "free_path_available": true, "overage_enabled": false, "overage_used_cents": 0, "overage_cap_cents": 0, "overage_rate_cents": 200, "trial_ends_at": "2026-04-01T10:00:00Z", "unlimited_runs": false, "concurrent_runs": 1, "concurrent_runs_limit": 10 } ``` ### Create Checkout `POST /billing/checkout` | Field | Type | Required | Description | |-------|------|----------|-------------| | plan_id | string | yes | Plan Id | | billing_period | string | no | Billing Period | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/billing/checkout \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"plan_id": "individual"}' ``` ```python session = client.billing.checkout(plan_id="individual", billing_period="monthly") ``` ### Create Portal `POST /billing/portal` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/billing/portal \ -H "Authorization: Bearer m8_your_key_here" ``` ```python url = client.billing.portal() ``` ### Create Topup `POST /billing/topup` | Field | Type | Required | Description | |-------|------|----------|-------------| | amount_cents | int | yes | Amount to add to the token balance, in cents ($5 min, $1M max). | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/billing/topup \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"amount_cents": 5000}' ``` ```python url = client.billing.topup(amount_cents=5000) ``` The call returns a Stripe Checkout URL; the balance is credited asynchronously after payment, so `balance()` won't reflect it until checkout completes. **Response** `200 OK` ```json { "checkout_url": "https://checkout.stripe.com/..." } ``` ### Get Balance `GET /billing/balance` ```bash curl \ https://api.m8tes.ai/api/v2/billing/balance \ -H "Authorization: Bearer m8_your_key_here" ``` ```python balance = client.billing.balance() ``` Amounts are in micro-USD (1 micro-USD = 1e-6 USD); `balance_usd` is a rounded display string. Each transaction `type` is one of `topup`, `debit`, `refund`, or `adjustment`. **Response** `200 OK` ```json { "balance_micros": 4992500, "balance_usd": "4.9925", "currency": "usd", "transactions": [ { "type": "debit", "amount_micros": -7500, "balance_after_micros": 4992500, "run_id": 42, "description": "run 42 inference", "created_at": "2026-03-27T10:00:00Z" } ] } ``` ### Get Plans `GET /billing/plans` | Parameter | Type | Description | |-----------|------|-------------| | include_free | bool | Include the non-purchasable Hobby plan in the catalog. | ```bash curl \ https://api.m8tes.ai/api/v2/billing/plans?include_free=true ``` ```python plans = client.billing.plans(include_free=True) ``` **Response** `200 OK` ```json [ {"slug": "free", "display_name": "Hobby", "included_runs": 150, "monthly_price_cents": 0, "annual_price_cents": 0, "inference_mode": "own_subscription", "overage_available": false, "overage_rate_cents": 200, "fair_use_cost_limit_cents": 50000, "max_concurrent_runs": 10}, {"slug": "individual", "display_name": "Individual", "included_runs": 1000, "monthly_price_cents": 2000, "annual_price_cents": 20000, "inference_mode": "own_subscription", "overage_available": false, "overage_rate_cents": 200, "fair_use_cost_limit_cents": 50000, "max_concurrent_runs": 25}, {"slug": "pro", "display_name": "Teams", "included_runs": 500, "monthly_price_cents": 100000, "annual_price_cents": 1000000, "inference_mode": "platform", "overage_available": true, "overage_rate_cents": 200, "fair_use_cost_limit_cents": 75000, "max_concurrent_runs": 50}, {"slug": "max_5x", "display_name": "Teams", "included_runs": 2000, "monthly_price_cents": 300000, "annual_price_cents": 3000000, "inference_mode": "platform", "overage_available": true, "overage_rate_cents": 200, "fair_use_cost_limit_cents": 300000, "max_concurrent_runs": 75}, {"slug": "max_20x", "display_name": "Teams", "included_runs": 5000, "monthly_price_cents": 500000, "annual_price_cents": 5000000, "inference_mode": "platform", "overage_available": true, "overage_rate_cents": 200, "fair_use_cost_limit_cents": 750000, "max_concurrent_runs": 100} ] ``` ### List Receipts `GET /billing/receipts` | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | ```bash curl \ https://api.m8tes.ai/api/v2/billing/receipts \ -H "Authorization: Bearer m8_your_key_here" ``` ```python page = client.billing.receipts() for r in page.data: print(r.created_at, r.amount_cents, r.receipt_url) ``` ### Update Alert Settings `PATCH /billing/alert-settings` | Field | Type | Required | Description | |-------|------|----------|-------------| | low_balance_threshold_cents | int | yes | Balance (cents) at which the low-balance warning fires (0 to $100k). | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/billing/alert-settings \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"low_balance_threshold_cents": 2000}' ``` ```python client.billing.set_alert_threshold(low_balance_threshold_cents=2000) ``` ### Update Auto Reload `PATCH /billing/auto-reload` | Field | Type | Required | Description | |-------|------|----------|-------------| | enabled | bool | yes | Turn auto-reload on or off. | | threshold_cents | int | no | Reload when the balance falls below this (cents). Required to enable. | | amount_cents | int | no | Amount charged per reload, in cents ($5 min, $10k max). Required to enable. | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/billing/auto-reload \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"enabled": true,"threshold_cents": 500,"amount_cents": 2000}' ``` ```python client.billing.set_auto_reload(enabled=True, threshold_cents=500, amount_cents=2000) ``` ### Update Overage Settings Opt in/out of usage overage and set the monthly spend cap (cents, max $10,000). The response echoes the full usage payload (the same shape as `GET /usage`). `PATCH /billing/overage` | Field | Type | Required | Description | |-------|------|----------|-------------| | enabled | bool | yes | Turn usage overage on or off. | | monthly_cap_cents | int | yes | Monthly overage spend ceiling in cents (0 to 1,000,000, i.e. up to $10,000). | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/billing/overage \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"enabled": true,"monthly_cap_cents": 5000}' ``` ```python client.billing.set_overage(enabled=True, monthly_cap_cents=5000) ``` **Response** `200 OK` ```json { "plan": "trial", "runs_used": 2, "runs_limit": 50, "cost_used": "0.12", "cost_limit": "20.00", "period_end": "2026-03-27T10:00:00Z", "free_path_available": true, "overage_enabled": false, "overage_used_cents": 0, "overage_cap_cents": 0, "overage_rate_cents": 200, "trial_ends_at": "2026-04-01T10:00:00Z", "unlimited_runs": false, "concurrent_runs": 1, "concurrent_runs_limit": 10 } ``` ### Get Receipt Invoice `GET /billing/receipts/{receipt_id}/invoice` ```bash curl \ https://api.m8tes.ai/api/v2/billing/receipts/{receipt_id}/invoice \ -H "Authorization: Bearer m8_your_key_here" ``` ```python invoice = client.billing.invoice(receipt_id) print(invoice.invoice_pdf_url) ``` **Response** `200 OK` ```json { "receipt_id": 7, "number": "A1B2C3D4-0001", "invoice_pdf_url": "https://pay.stripe.com/invoice/acct_\u2026/pdf", "hosted_invoice_url": "https://invoice.stripe.com/i/acct_\u2026" } ``` --- ## Value Maintain customer-specific use cases inferred from real agent work, attribute run cost, record sourced outcomes, confirm inferred financial claims, and compare verified value with metered platform cost. Pending and unquantified outcomes stay separate from ROI. ### Create Use Case `POST /value/use-cases` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | Display name. | | goal | string | yes | Goal | | measurement_model | object | no | Measurement Model | | responsible_user_id | int | no | Responsible User Id | | source_run_id | int | no | Source Run Id | | user_id | string | no | User Id | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/value/use-cases \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support agent","goal": "..."}' ``` ```python use_case = client.value.create_use_case(name="reduce acquisition waste", goal="Keep spend focused", measurement_model={"source": "Google Ads"}) ``` ### Get Value Report `GET /value/report` | Parameter | Type | Description | |-----------|------|-------------| | start_date | string | | | end_date | string | | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/value/report?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python report = client.value.report() ``` ### List Use Cases `GET /value/use-cases` | Parameter | Type | Description | |-----------|------|-------------| | status | string | Filter by status (`running`, `paused`, `awaiting_approval`, `completed`, `failed`, `cancelled`, `closed`, `archived`) | | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/value/use-cases?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python use_cases = client.value.list_use_cases() ``` ### Get Use Case `GET /value/use-cases/{use_case_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/value/use-cases/{use_case_id} \ -H "Authorization: Bearer m8_your_key_here" ``` ```python use_case = client.value.get_use_case(12) ``` ### Update Use Case `PATCH /value/use-cases/{use_case_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | no | Display name. | | goal | string | no | Goal | | measurement_model | object | no | Measurement Model | | responsible_user_id | int | no | Responsible User Id | | status | string | no | Status | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/value/use-cases/{use_case_id} \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"name": "support agent"}' ``` ```python use_case = client.value.update_use_case(12, status="paused") ``` ### Confirm Observation `POST /value/observations/{observation_id}/confirm` | Field | Type | Required | Description | |-------|------|----------|-------------| | decision | string | yes | `allow` or `deny` the pending tool request. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/value/observations/{observation_id}/confirm \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"decision": "confirmed"}' ``` ```python observation = client.value.confirm_observation(9, "confirmed") ``` ### Create Observation `POST /value/use-cases/{use_case_id}/observations` | Field | Type | Required | Description | |-------|------|----------|-------------| | kind | string | yes | Kind | | evidence_level | string | yes | Evidence Level | | title | string | yes | Title | | amount_usd | number | no | Amount Usd | | metric_name | string | no | Metric Name | | metric_value | number | no | Metric Value | | metric_unit | string | no | Metric Unit | | evidence | object[] | yes | Evidence | | source_run_id | int | no | Source Run Id | | observed_at | string | yes | Observed At | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/value/use-cases/{use_case_id}/observations \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"kind": "revenue_generated","evidence_level": "measured","title": "...","evidence": [{"source": "system-of-record","record_id": "record_123"}],"observed_at": "..."}' ``` ```python observation = client.value.create_observation(12, kind="cash_saved", evidence_level="measured", title="Waste removed", amount_usd="500", evidence=[{"source": "ledger", "record_id": "line_42"}], observed_at="2026-08-20T12:00:00Z") ``` ### Link Runs `POST /value/use-cases/{use_case_id}/runs` | Field | Type | Required | Description | |-------|------|----------|-------------| | run_ids | int[] | yes | Run Ids | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/value/use-cases/{use_case_id}/runs \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"run_ids": []}' ``` ```python client.value.link_runs(12, [42, 43]) ``` ### List Observations `GET /value/use-cases/{use_case_id}/observations` | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/value/use-cases/{use_case_id}/observations \ -H "Authorization: Bearer m8_your_key_here" ``` ```python observations = client.value.list_observations(12) ``` --- ## Account Account-level data controls: export all of your account's data, or delete the account and its data (GDPR/CCPA). ### Delete Account `DELETE /account` ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/account \ -H "Authorization: Bearer m8_your_key_here" ``` ### Change Password `POST /account/password` | Field | Type | Required | Description | |-------|------|----------|-------------| | current_password | string | yes | The account's current password, to prove possession. | | new_password | string | yes | The replacement password, at least 8 characters. Revokes every session (a fresh pair is returned, or an MFA challenge when 2FA is on) but deliberately leaves API keys and webhook tokens working; only a password RESET, which cannot prove you knew the old password, revokes those. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/account/password \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"current_password": "...","new_password": "..."}' ``` ```python client.account.change_password(current_password="…", new_password="…") ``` ### Export Account `GET /account/export` ```bash curl \ https://api.m8tes.ai/api/v2/account/export \ -H "Authorization: Bearer m8_your_key_here" ``` ### Revoke Sessions Session-authenticated only. An `m8_` API key is refused with HTTP 403 — a credential pasted into CI must not be able to sign the account owner out of their browser. Pass a browser/CLI session access token instead. `POST /account/sessions/revoke` ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/account/sessions/revoke \ -H "Authorization: Bearer your_session_token_here" ``` ```python client.account.revoke_sessions() ``` --- ## Webhooks Register URLs to receive signed event notifications when runs change status. ### Create Webhook `POST /webhooks` | Field | Type | Required | Description | |-------|------|----------|-------------| | url | string | yes | HTTPS URL to receive events | | events | string[] | no | Events: run.started, run.completed, run.failed, run.cancelled, run.awaiting_input, run.message_received, run.message_cancelled | | user_id | string | no | End-user scope (maps to end_user_id). Omit for account-level — only account-level runs deliver here; include it so that end-user's runs deliver here and nowhere else. | ```bash curl \ -X POST \ https://api.m8tes.ai/api/v2/webhooks \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"url": "https://your-app.com/webhooks/m8tes","events": ["run.completed"],"user_id": "customer_123"}' ``` ```python webhook = client.webhooks.create( url="https://your-app.com/webhooks/m8tes", events=["run.completed"], user_id="customer_123", ) print(webhook.secret) # Save this; only shown on creation ``` > **Note:** The `secret` is only returned on creation. Store it securely. **Response** `201 Created` ```json { "id": 2, "url": "https://your-app.com/webhooks/m8tes", "events": [ "run.completed" ], "secret": "a1b2c3d4e5f6...", "active": true, "user_id": "customer_123", "created_at": "2026-01-15T10:00:00Z", "updated_at": "2026-01-15T10:00:00Z" } ``` ### List Webhooks Secrets are masked in list responses. `GET /webhooks` | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last item ID | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/webhooks?user_id=customer_123 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python webhooks = client.webhooks.list(user_id="customer_123") ``` ### Get Webhook `GET /webhooks/{webhook_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/webhooks/2 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python webhook = client.webhooks.get(2, user_id="customer_123") ``` **Response** `200 OK` ```json { "id": 2, "url": "https://your-app.com/webhooks/m8tes", "events": [ "run.completed" ], "secret": "a1b2c3d4e5f6...", "active": true, "user_id": "customer_123", "created_at": "2026-01-15T10:00:00Z", "updated_at": "2026-01-15T10:00:00Z" } ``` ### Update Webhook `PATCH /webhooks/{webhook_id}` | Field | Type | Required | Description | |-------|------|----------|-------------| | url | string | no | HTTPS endpoint URL to receive deliveries. | | events | string[] | no | Event types to subscribe to, e.g. `run.completed`. | | active | bool | no | Whether it is enabled. | | rotate_secret | bool | no | Generate a new signing secret | ```bash curl \ -X PATCH \ https://api.m8tes.ai/api/v2/webhooks/2 \ -H "Authorization: Bearer m8_your_key_here" \ -H "Content-Type: application/json" \ -d '{"events": ["run.started","run.completed"]}' ``` ```python client.webhooks.update(2, events=["run.started", "run.completed"], user_id="customer_123") ``` **Response** `200 OK` ```json { "id": 2, "url": "https://your-app.com/webhooks/m8tes", "events": [ "run.completed" ], "secret": "a1b2c3d4e5f6...", "active": true, "user_id": "customer_123", "created_at": "2026-01-15T10:00:00Z", "updated_at": "2026-01-15T10:00:00Z" } ``` ### Delete Webhook `DELETE /webhooks/{webhook_id}` | Parameter | Type | Description | |-----------|------|-------------| | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ -X DELETE \ https://api.m8tes.ai/api/v2/webhooks/2 \ -H "Authorization: Bearer m8_your_key_here" ``` ```python client.webhooks.delete(2, user_id="customer_123") ``` ### List Deliveries View delivery attempts for a webhook endpoint. `GET /webhooks/{webhook_id}/deliveries` | Parameter | Type | Description | |-----------|------|-------------| | limit | int | Max results (1-100, default 20) | | starting_after | int | Cursor: last delivery ID | | user_id | string | Scope to one end-user (maps to end_user_id). Omit for the account view. | ```bash curl \ https://api.m8tes.ai/api/v2/webhooks/2/deliveries \ -H "Authorization: Bearer m8_your_key_here" ``` ```python deliveries = client.webhooks.list_deliveries(2, user_id="customer_123") for d in deliveries.data: print(d.event_type, d.status, d.attempts) ``` --- ## Errors All errors follow a standard format: ```json { "error": { "type": "not_found", "message": "Agent not found", "code": 404, "request_id": "req_abc123", "doc_url": "https://www.m8tes.ai/docs/api-errors#error-types" } } ``` `doc_url` links to the docs page for that error type. Include `request_id` when you contact support. `code` is always the HTTP status. When a semantic app code exists (e.g. `RUN_LIMIT_REACHED`, `TOKEN_BALANCE_DEPLETED`, `unknown_query_parameter`) it is on `error.error_code`. Branch on that, never on the message text. `error.details.error_code` carries the same value for backward compatibility, alongside any extra context fields. | Status | Type | SDK Exception | Description | |--------|------|---------------|-------------| | 400 | invalid_request_error | ValidationError | Invalid request | | 401 | authentication_error | AuthenticationError | Invalid or missing API key | | 402 | billing_error | BillingError | Run or cost limit reached, or subscription issue | | 403 | permission_error | PermissionDeniedError | Insufficient permissions | | 404 | not_found | NotFoundError | Resource not found | | 405 | method_not_allowed | APIError | HTTP method not supported on this path | | 409 | conflict_error | ConflictError | Resource conflict (duplicate, not active) | | 422 | validation_error | ValidationError | Invalid request parameters | | 429 | rate_limit_error | RateLimitError | Too many requests (auto-retried by SDK) | | 500+ | api_error | APIError | Server error (auto-retried by SDK) | # TypeScript SDK Source: https://www.m8tes.ai/docs/typescript-sdk Stream runs, schedule tasks, and handle approvals from your backend. ## Install ```bash npm i @m8tes/sdk ``` Node 18.17+, Deno, or Bun. No runtime dependencies. `verifySignature` needs `node:crypto`; enable Node compatibility on edge runtimes. ## Your first run **First, finish [Quick Start](/docs/quickstart#typescript): get a key and connect a model (or use the $1 test credit).** A fresh account needs a model connection or funding before it can run. ```ts import { M8tes } from "@m8tes/sdk"; const client = new M8tes(); // reads M8TES_API_KEY // Personal development only: this disables strict user_id checks account-wide. // This persists. For customer-facing apps, keep strict mode on and pass user_id. await client.settings.update({ require_end_user_id: false }); const run = client.runs.create({ message: "Draft a follow-up for Acme's stalled renewal — SSO pricing and a Q3 start.", model: "grok-4.6", // matches the connected xAI plan }, { raiseOnError: true }); for await (const chunk of run.iterText()) process.stdout.write(chunk); console.log(run.runId); ``` For customer-facing apps, keep strict mode on, pass `user_id`, and [fund the prepaid wallet](/docs/quickstart#production-embeds-use-prepaid). ## SDK coverage | Client | Coverage | Versioning | |---|---|---| | [Python SDK](https://pypi.org/project/m8tes/) | Full API; new resources land here first | Semver-stable | | TypeScript | Core loop, models, model connections, billing, account | Pre-1.0; signatures may change | | [HTTP](/docs/api-reference) | Every V2 endpoint | `/api/v2` | Pin a concrete npm version in production. The streaming protocol, `m8tes.stream.v2`, is semver-stable. ## Naming | Surface | Convention | Examples | |---|---|---| | Request and response fields | snake_case | `user_id`, `created_at` | | SDK methods and options | camelCase | `createAsync`, `apiKey` | | Stream members and events | camelCase | `runId`, `toolName` | Use `agent_id` in the SDK. It accepts the wire alias `teammate_id` too. ## Server-only ![Your browser calls your backend, which keeps the secret API key and calls m8tes.](/docs/diagrams/server-boundary.svg) Keep the `m8_` key on your server. The client refuses browser construction. Use [React embed](/docs/embed-a-ui) for a chat UI and server proxy. | Build next | Guide | |---|---| | Isolate customers | [Multi-tenancy](/docs/users) | | Read live events | [Streaming](/docs/streaming) | | Handle failures | [Errors](/docs/api-errors) | | Receive results | [Webhooks](/docs/webhooks-guide) | | Page through records | [Pagination](/docs/api-pagination) | Next: [Quick Start](/docs/quickstart) · [Runs](/docs/runs) · [Embed a UI](/docs/embed-a-ui) # CLI Source: https://www.m8tes.ai/docs/cli Use the `m8tes` CLI to inspect agents, run tasks, manage app connections, and debug account state from your terminal. ## Installation ```bash pip install -U "m8tes>=4.49.0" m8tes auth login # prompts for email + password, saves credentials locally ``` For an API account, set `M8TES_API_KEY` and pass your end-user scope. New API accounts include $1 test credit for `deepseek-v4-1-flash`: ```bash export M8TES_API_KEY=m8_your_key_here m8tes agent task "Say hello" --user-id hello_world --model deepseek-v4-1-flash ``` With `--user-id` and no agent ID, V2 finds or creates an agent in that scope. Pass an ID before the message to target an existing agent. This keeps strict scope checks enabled. Credentials resolve in this order: the global `--api-key` flag, `M8TES_API_KEY`, then your saved login. An explicit key never refreshes the saved login. Agent and saved-task management commands also accept `--user-id`. Keep the same scope when inspecting or changing resources: ```bash m8tes agent list --user-id hello_world m8tes agent get 7 --user-id hello_world m8tes task list --user-id hello_world m8tes task execute 42 --user-id hello_world m8tes run list --user-id hello_world m8tes run get 123 --user-id hello_world m8tes run list-agent 7 --user-id hello_world ``` Replace the example IDs with your own. The command reference below uses account-level examples; append `--user-id` for tenant-scoped resources wherever supported. Run conversation, usage, tools, and retry commands resolve the existing run directly. `agent chat` accepts the same `--user-id` and `--model` flags for existing agents. Scope applies to agent selection and resume validation; replies inherit the existing run's scope and model. `--model` applies to new runs only, including after `/clear`. ## Everyday commands ```bash m8tes agent task "Summarize the latest AI news" # one-off task (auto-detects your agent) m8tes agent chat # interactive multi-turn conversation m8tes agent list # list agents m8tes run list # recent runs m8tes run get 123 # full run details ``` `mate` is a permanent alias (`m8tes mate task …` still works). Have more than one agent? Pass its id: `m8tes agent task 7 "Prepare the weekly report"`. Inside a chat session use `/exit`, `/clear`, or `/resume `; `m8tes agent chat --resume 42` reopens a previous run. ## Command reference
Auth ```bash m8tes auth status # Check current login m8tes auth logout # Clear saved credentials m8tes auth register # Create a new account m8tes auth usage # Show plan limits and current usage m8tes auth resend-verify ```
Tasks and output formats ```bash m8tes agent task "Do X" --output verbose # Rich UI with tool calls (default) m8tes agent task "Do X" --output compact # Text-only output m8tes agent task "Do X" --output json # Raw event JSON m8tes agent task "Do X" --debug # Show event traces and counts ``` Reusable task definitions attach to agents and trigger manually, on a schedule, via webhook, or by email: ```bash m8tes task list # List all tasks m8tes task list --agent-id 7 # Filter by agent m8tes task get 42 # Show task details m8tes task create # Interactive creation wizard m8tes task create --non-interactive \ --agent-id 7 --name "Weekly Report" \ --instructions "Generate metrics" # Scripted creation m8tes task execute 42 # Run a saved task m8tes task update 42 --name "New Name" # Update task fields m8tes task enable 42 # Re-enable a disabled task m8tes task disable 42 # Disable a task m8tes task archive 42 # Archive a task ```
Agent management Tools are optional. Use app IDs from `m8tes apps list` when adding integrations (for example `gmail` or `slack`). ```bash m8tes agent list # List all agents m8tes agent list --include-disabled # Include disabled agents m8tes agent get 7 # Show agent details m8tes agent create # Interactive creation wizard m8tes agent create --non-interactive \ --name "Ops Bot" \ --tools gmail slack \ --instructions "Handle ops tasks" # Scripted creation m8tes agent update 7 # Interactive update m8tes agent update 7 --non-interactive \ --name "New Name" # Scripted update m8tes agent enable 7 # Re-enable a disabled agent m8tes agent disable 7 # Soft-disable (preserves history) m8tes agent archive 7 # Archive (hidden from listings) ```
Run inspection ```bash m8tes run list # List recent runs m8tes run list-agent 7 # Runs for a specific agent m8tes run get 123 # Full run details m8tes run conversation 123 # View chat messages m8tes run usage 123 # Token usage and costs m8tes run set-permission-mode 123 approval m8tes run tools 123 # Tool executions m8tes run tools 123 --verbose # With arguments and results m8tes run audit-logs --limit 20 # API request audit logs m8tes run audit-logs --method POST --resource-type run --status-code 201 m8tes run audit-logs --auth api_key # only your SDK/API calls, not the web app's ``` `set-permission-mode` also works while a run is paused. Switching to `autonomous` resumes pending tool approvals, but `AskUserQuestion` still waits for `runs.answer()`.
App connections ```bash m8tes apps list m8tes apps list --user-id cust_123 m8tes apps connect-api-key gemini sk_live_123 m8tes apps connect-oauth gmail --redirect-uri https://yourapp.com/oauth/callback --user-id cust_123 m8tes apps connect-complete gmail conn_abc123 --user-id cust_123 m8tes apps disconnect gmail --user-id cust_123 ```
Most commands have short aliases: `m8tes m t "Do X"` for `agent task` (`m` = agent alias), `m8tes m ch` for chat, `m8tes r ls` for `run list`, `m8tes a l` for `auth login`, `m8tes task x` for `task execute`. ## Development Mode ```bash m8tes --dev agent task "Test locally" # targets http://127.0.0.1:8000 m8tes --base-url https://staging.m8tes.ai agent task "Staging" # any backend ``` Override the local port with `M8TES_PORT=9000`. ## CLI vs SDK The CLI uses the same backend as the [Python SDK](/docs/quickstart). Choose based on your workflow: | | CLI | SDK | |---|---|---| | **Best for** | Quick tasks, debugging, exploration | Applications, automation, integrations | | **Auth** | Email/password login | API key (`m8_`) | | **Streaming** | Built-in terminal output | `RunStream` iterator | Next: [Quickstart](/docs/quickstart) · [Errors](/docs/api-errors)