AI Agent Tool Permissions: Read-Only vs Write SaaS API
How to split AI agent tool permissions for SaaS APIs: read-only tool lists, server-side tenant scoping, write approvals, MCP and SDK code with TrendsAGI.

AI agent tool permissions for a SaaS API come down to one design decision: which tools the agent can see, which layer enforces that boundary, and what happens when a tool would change data. If you are wiring an agent into a SaaS product through a REST API, an SDK or an MCP server, the safe default is a read-only tool surface enforced server-side, with write tools added deliberately and gated behind human approval. Permission checks written into the task description are not enforcement; they are suggestions the agent can route around.
This guide compares the enforcement models now shipping in 2026, shows a read-only/write taxonomy, gives working code with the TrendsAGI SDK and MCP server, and closes with the audit-log checklist reviewers actually ask for.
What an AI agent tool permission actually is
A tool is a named function the agent can call, described to it by name, description and a JSON Schema for inputs. Permissions decide three things separately: discovery (can the agent see the tool at all), authorization (may this caller run it against this data), and effect (read versus change). Most incidents come from collapsing those into one check placed in the wrong layer.
Powabase documents this shape directly: its builtin catalog includes database_query, database_write, http_request, code_execute, storage_read, storage_write, web_search and web_scrape, alongside custom tools defined by name, description, JSON Schema inputs and an endpoint URL. Notice how the read and write variants are distinct tools, which is the cheapest place to draw the line: a read-only agent simply never receives database_write, storage_write or code_execute in its tool list.
Read-only versus write: a taxonomy you can ship
| Class | Examples | Exposure | Approval |
|---|---|---|---|
| Pure read | list trends, get analytics, search insights, read usage | Always available to authenticated callers | None |
| Read with tenant data | recommendations, crisis events, watchlist | Available, scoped server-side | None, but logged |
| Draft write | propose a campaign change, propose a watchlist entry | Available as a non-executing draft | Human confirms |
| Direct write | create watchlist item, execute an applied ad change | Opt-in only, per task | Human confirms, then executes |
| Dangerous | arbitrary SQL, code execution, raw HTTP to any host | Disabled for standard agents | Separate reviewed service |
The split must be enforced where the agent cannot reach it. In TrendsAGI that boundary is the transport: the hosted MCP server at https://api.trendsagi.com/mcp exposes read-only tools only, while the local stdio MCP server adds write tools only when you pass --allow-writes. The same applies to the Python SDK's agent tools, which are read-only by default.
from trendsagi import TrendsAGIClient, TrendsAGITools
import os
client = TrendsAGIClient(api_key=os.environ["TRENDSAGI_API_KEY"])
# Read-only surface: this is what a normal agent should get.
tools = TrendsAGITools(client)
print([t["name"] for t in tools.openai()])
# Write tools are opt-in and separate.
writer = TrendsAGITools(client, allow_writes=True)Filter the tool list per user, not per conversation
The most portable fix for over-broad agents is to build the tool list after resolving the caller, so the agent never sees a tool it cannot use. A practitioner incident report describes exactly this: permission instructions in the task description leaked another team's customer data, and the correction was filtering tools per user plus returning drafts instead of performing writes. The lesson generalizes: if the agent can inspect a tool, assume it may call it.
scoped-mcp takes the namespace version of the same idea, using interaction_permissions with auto_approved entries and NamespaceScope-based tool modules; its example agent saw no API tokens, touched no other agent's data and modified no operator dashboards. agentcop's sandbox applies default-deny to paths and domains. maia.ai's Set Task Permissions endpoint (PUT /dpc/v1/ai/agents/tasks/{taskId}/permissions) replaces the full set of granted tool permissions for a task, and an empty list clears them all.
For TrendsAGI, the read side is small and enumerable, so filtering is usually a no-op on content and a real step on writes:
READ_TOOLS = {
"list_trends", "list_trend_categories", "search_trend_insights",
"get_trend", "get_trend_ai_insights", "get_trend_analytics",
"list_recommendations", "list_crisis_events", "list_watchlist",
"get_api_usage",
}
WRITE_TOOLS = {"add_to_watchlist", "update_recommendation"}
def tools_for(caller):
allowed = set(READ_TOOLS)
if caller.can_write:
allowed |= WRITE_TOOLS
return [t for t in writer.openai() if t["name"] in allowed]Scope tenant and data scope on the server, never in arguments
If a tenant identifier or data scope is a tool argument the agent fills in, it is a request parameter, not a permission. Resolve them from the authenticated caller and bind them server-side. Powabase's custom tools, described by name, description, JSON Schema and an endpoint URL, can still leak when the endpoint trusts client-supplied identity; the schema should not contain the tenant at all.
agentcop's AgentSandbox accepts allowed_paths, allowed_domains and max_execution_time, raises SandboxViolation for paths or domains outside the allowlist and denies by default everything not declared. That is the correct posture for anything that touches files or egress: allowlist the domain, cap the time, and fail loudly.
Human approval for write tools, with drafts that expire
The approval pattern that holds up is draft-then-confirm. A write tool returns a proposed change, the agent shows it, a human confirms, and execution happens in a separate call bound to that confirmation. Three details are usually missing from writeups and matter in production:
- Expiry. A pending confirmation needs a short TTL, or a stale approval will execute against changed data.
- Idempotency. Confirmation must carry a key so a retried call does not apply twice.
- Replay safety. Re-submitting a completed confirmation should return the original result, not act again.
agentcop's ApprovalBoundary shows the threshold form of this: auto-approve below a threshold and hold high-risk actions. Set the threshold on blast radius, not on confidence, and give held actions an explicit expiry and escalation owner.
TrendsAGI's update_recommendation and add_to_watchlist are the write tools; the REST surface is POST /api/intelligence/recommendations/{id}/action and POST /api/user/interests, where watchlist alerts use alert_condition_type of volume_threshold or percentage_growth. For ad platforms, TrendsAGI previews changes before any explicit execution, so the approval step has a concrete artifact to confirm.
End-to-end: a rising trend becomes an approved campaign change
The agent below runs read-only until a human approves. It finds a fast-rising trend, checks analytics, reads the insight, asks for a recommendation, and returns a draft for approval.
from trendsagi import TrendsAGIClient, TrendsAGITools
import os
client = TrendsAGIClient(api_key=os.environ["TRENDSAGI_API_KEY"])
tools = TrendsAGITools(client) # read-only by default
# 1. Find the fastest-rising trend in a category.
trends = client.get_trends(category="Technology", sort_by="velocity", limit=5)
trend_id = trends[0]["id"]
# 2. Check whether it is accelerating over the last day.
analytics = client.get_trend_analytics(trend_id, period="24h")
# 3. Read the AI insight: summary, themes, sentiment, audience, angles.
insight = client.get_ai_insights(trend_id)
# 4. Ask for high-priority recommendations backed by that evidence.
recs = client.get_recommendations(limit=10, priority="high")
for r in recs[:3]:
print(r["id"], r.get("title"), r.get("confidence"))
# 5. Stop here and return a draft. A human approves before any write.
draft = {
"trend_id": trend_id,
"recommendation_id": recs[0]["id"],
"proposed_action": recs[0].get("title"),
"evidence": {"velocity": trends[0].get("velocity"), "analytics": analytics},
}
print(draft)Only after approval does a separately instantiated writer call POST /api/intelligence/recommendations/{id}/action. Trend velocity is a measured snapshot rate (posts per hour), not a forecast or a unique-user count; recommendation confidence measures evidence completeness, not probability of success. State both in the approval screen so the reviewer is not misled.
Wire the same permissions into MCP and frameworks
Keep one permission decision and project it into each transport. The local MCP server installs with pip install "trendsagi[mcp]" and runs as trendsagi mcp with TRENDSAGI_API_KEY set; add --allow-writes only for the agent that has a working approval UI. Claude Code can connect with claude mcp add --transport http trendsagi https://api.trendsagi.com/mcp, which is read-only. Each connection is its own API key and each tool call counts as one API request.
import json
tools = TrendsAGITools(client)
result = tools.call("list_trends", {"category": "Technology"})
if "error" in result:
print(json.dumps(result)) # {"error": ..., "detail": ...}
else:
first = result["result"]["trends"][0]
print(first["id"], first["volume"])Tool calls return {"result": ...} on success and {"error": ..., "detail": ...} on failure rather than raising, so log both shapes. tools.openai(), tools.openai_responses(), tools.anthropic() and tools.langchain() give you the filtered list in each framework's format. A small explicit surface suits Tool Mode; a broad workspace suits Code Mode, per the workspace model documented for @weavz-io/sdk 0.1.1, where workspaces scope connections and integrations sit under readable aliases. In browser agents, WebMCP lets the signed-in app register the same read-only tools with no connector or key; on Cursor, the Agent SDK reaches the hosted tools through a single connection file declared as read effects.
Rate limits, denials and audit logging
Expose usage to the agent as a cheap read: get_api_usage in the SDK, GET /api/user/api-usage in REST. Have the agent check before a burst rather than discovering limits through failures. Log four event types for every tool call: allowed, denied, drafted and approved. Denials are the events reviewers chase, so include the resolved caller, the tool name, the reason and the timestamp. Record the approval decision, the approver and the exact draft hash that was confirmed.
Frequently asked questions
How do I split my agent's tool surface into read-only and write tools, and where should that split be enforced?
Make read and write separate named tools, ship read-only by default, and enforce the split at the transport or server boundary, not in the task text. TrendsAGI does this with a read-only hosted MCP server, an opt-in --allow-writes flag on the local server, and a default read-only TrendsAGITools(client).
Why is a permission check in the prompt unsafe, and what should replace it?
A documented incident shows prompt-level permission checks leaked another team's customer data, because the agent could still reach the tool. Replace instructions with enforced controls: filter the tool list per user, bind tenant and scope server-side, and return drafts for writes.
How do I filter the tool list per user so the model never sees tools it cannot use?
Resolve the caller first, compute an allowed set, and build the tool list from it. Keep the allowlist explicit and small, then project it into each framework with tools.openai(), tools.anthropic() or tools.langchain().
What human-in-the-loop approval pattern should write tools use, and how do drafts work?
Write tools return a draft with the evidence behind it; a human confirms; execution is a separate call bound to that confirmation with a TTL and an idempotency key. agentcop's ApprovalBoundary is a threshold variant: auto-approve low-risk actions, hold the rest.
How do I scope data server-side by tenant and data_scope so the LLM cannot pass them?
Read tenant identity from the authenticated session or API key and never accept it as a tool argument. If it appears in a JSON Schema, remove it; a sandbox with allowed_paths and allowed_domains then contains anything that reaches outward.
What do permission APIs, sandboxes and MCP gateways each enforce, and what are the trade-offs?
A per-task permission API like maia.ai's replaces the full granted set, which is precise but needs a caller that sets it correctly. Sandboxes like agentcop enforce default-deny paths, domains and timeouts but not business-level intent. MCP gateways and namespaced modules like scoped-mcp scope tools per namespace and per agent. None of them replace a filtered tool list plus approval on writes.
How we researched this
Prepared by TrendsAGI Research. Facts checked on 30 October 2026 against the sources listed below.
Capability descriptions and code use TrendsAGI's documented REST API, Python SDK and MCP server. External facts come from the sources below.
| Claim | Applies to | Source |
|---|---|---|
| Workspaces scope connections, integration configuration, MCP servers, partials, triggers, and end-user access, and integrations are added under purpose-readable aliases so agents and logs show stable names. | 0.1.1 | pypi.org |
| Code Mode is described as the best default for broad agent workspaces, while Tool Mode is useful for small explicit tool surfaces. | 0.1.1 | pypi.org |
| A Set Task Permissions endpoint (PUT /dpc/v1/ai/agents/tasks/{taskId}/permissions) replaces the full set of granted tool permissions for a task, and passing an empty list clears all permissions. | All plans | docs.maia.ai |
| The platform provides builtin tools including database_query, database_write, http_request, code_execute, storage_read, storage_write, web_search and web_scrape, plus custom tools defined by name, description, JSON Schema inputs and an endpoint URL. | All plans | docs.powabase.ai |
| AgentSandbox accepts allowed_paths, allowed_domains and max_execution_time, raising SandboxViolation for paths or domains outside the allowlist and SandboxTimeoutError when time is exceeded, and the package denies by default everything not declared. | 0.4.11 | pypi.org |
| agentcop includes an ApprovalBoundary human-in-the-loop gate that auto-approves below a threshold and holds high-risk actions. | 0.4.11 | pypi.org |
| scoped-mcp uses interaction_permissions with auto_approved entries and NamespaceScope-based tool modules, and its example agent saw no API tokens, touched no other agent's data and modified no operator dashboards. | 1.9.0 | pypi.org |
We re-research and rewrite this page at least every four months; the updated date only changes when the facts are re-checked.
Sources
- Set Task Permissions - Maia Documentation (maia.ai) — docs.maia.ai
- agentcop - deny by default (PyPI) — pypi.org
- Tools - Powabase — docs.powabase.ai