Skip to content
mcp-data-platform composable mcp data platform
v1.x part of txn2 ↗

Available Tools

mcp-data-platform provides tools from five integrated toolkits. Each tool can be invoked by name through any MCP client.

Reducing token usage with tool visibility

The full tool list is 30-35 tools depending on configuration. Deployments that only use a subset can configure tools.allow and tools.deny at the top level of platform.yaml to hide unused tools from tools/list responses. This saves LLM context tokens without affecting authorization. See Configuration for details.

Tools Summary

Toolkit Tool Description
Trino trino_query Execute read-only SQL queries (SELECT, SHOW, DESCRIBE, EXPLAIN)
Trino trino_execute Execute any SQL including write operations (INSERT, UPDATE, DELETE, CREATE, DROP)
Trino trino_explain Get query execution plans
Trino trino_browse Browse the catalog hierarchy: list catalogs, schemas, or tables
Trino trino_describe_table Get table schema and metadata
Trino trino_export Export query results directly to a portal asset (CSV, JSON, JSON lines, Parquet, Markdown, text)
Trino trino_list_connections List configured Trino connections
S3 s3_list List buckets, or the objects in one bucket
S3 s3_object Get, describe, upload, copy, delete, or presign one object
Knowledge search The one way to discover: balanced, grouped-by-source results across the catalog, the glossary, tags, domains and data products, knowledge pages, memory, insights, feedback, assets, prompts, managed scripts, API endpoints, and connections
Knowledge fetch Read a search result in full: dereferences any reference search emits (knowledge page, dataset, definition, asset, prompt, managed script, connection) to its complete content, under the same per-user scope
Memory memory_capture The one way to record knowledge: sink-class routed, recall-first
Knowledge apply_knowledge Review and promote reviewed captures to the catalog (admin-only)
Memory memory_manage Manage existing memories: update, forget, list, review_stale, review_duplicates, consolidate (opt-in per persona)
Portal save_asset Save AI-generated content as an asset (JSX, HTML, SVG, Markdown, CSV, etc.): every document an agent produces, whatever the user calls it
Portal manage_asset List, get, update, delete, or relevance-search saved assets and collections, edit asset content in place (patch, locate, get_content, outline, stats, diff), and share an asset with a person or as a link (share, list_shares, revoke_share)
Portal manage_table Make a stored CSV or JSON-lines file queryable as a table and manage what is registered over it (register, list, unregister), or check that a table could be registered on a connection (check). Takes the reference a search hit carries, so it serves an uploaded resource and a saved asset through one action
Portal manage_resource Manage a file in the managed resource library (create, replace_content, update, get, list, delete, extract): the files a saved asset references, and the members of a stored archive written out as files of their own. A replacement keeps the resource's id, URI and filename, so every asset referencing it serves the new bytes without being re-saved; create with if_exists=replace makes landing one rolling file idempotent, and a delete is refused while anything still points at the file
Portal manage_feedback Review, open and respond to feedback (list pending across everything, create, get, reply, resolve, request/respond validation, edit and delete a message)
Platform platform_find_tools Find the most relevant tools for a natural-language task, ranked by semantic similarity (persona-scoped)
Platform manage_prompt Resolve and run prompts by any handle (use), plus create, update, delete, list, get, the script-reference commands (attach_script, detach_script), and the content verbs (patch, locate, get_content, outline, stats, diff)
Platform show_prompts Render the prompt library as an interactive browser for the human (presentation-only; call only when the user wants to see their prompts)
Platform manage_application Release an application built on the platform (schema, scripts, endpoints, forms, sources, assets) as a pinned set, roll it back, and move it between deployments
Platform manage_approval Request an administrator's approval of a form's version for people who are not signed in, list requests, and decide one
Platform manage_script Build and change automations. Author, validate, and dry-run managed scripts: small governed Starlark programs for a process whose logic is settled and will repeat, and the tool for a request to automate work or run it on a schedule
Platform show_scripts Open the user's automations (scripts), their schedules, and their run history on the portal's Automations page (presentation-only; call only when the user wants to see them)

The purpose argument

Data-access tools carry one argument the platform adds rather than the toolkit: purpose, a single sentence naming the wider task the agent is working on and why this call serves it. It appears on the tool's input schema like any other argument, the platform takes it off the request before the tool handler runs, and it is written to the call's audit row (docs/server/audit.md). No toolkit ever sees it, and it never changes what the tool does.

{
  "sql": "SELECT region, sum(amount) FROM orders WHERE quarter = 'Q3' GROUP BY region",
  "purpose": "Checking whether order volume fell in the western region for the board deck."
}

Write the question behind the call, not a restatement of the arguments: "Running a SQL query" is worthless to the person who later reads the row. Do not repeat argument values in it, and never put personal data, credentials, or secrets in it.

By default the argument appears on search, fetch, trino_query, trino_execute, trino_export, trino_describe_table, api_invoke_endpoint, api_export, s3_object, s3_list, and every tool proxied from an upstream MCP server through the gateway toolkit. Orientation and platform-management tools (platform_info, list_connections, platform_find_tools, memory_*, manage_*, save_asset) do not carry it.

A call on one of these tools that states no purpose is refused with PURPOSE_REQUIRED (error category purpose_required); retry the same call with a purpose rather than looking for another tool. The refusal reaches only callers that thread a session_id handle, so an MCP App, a managed script, and the gateway REST shim are never refused for it. Both the gated set and the refusal are configurable — see Purpose Configuration.

The name is purpose, not intent, because search already takes an intent argument that is the query text.


Tool annotations

Every tool the platform registers advertises MCP tool annotations on tools/list, so a client can tell a read from a write before it calls anything.

Annotation What it says
readOnlyHint true when no call to the tool modifies state.
destructiveHint On a write, true when some action removes or overwrites state that is already there, false when every action only adds.
idempotentHint true on a read, where calling again changes nothing.

Clients are told by the specification to assume a tool is not read-only when readOnlyHint is absent, and some act on that by asking the user to confirm every unannotated call. The platform therefore states the hint on every tool rather than leaving any to the default: a session that opens with platform_info, then search, then fetch is three pure reads, and on a strict client each one used to arrive as a write confirmation.

The hint describes the tool, not the call. manage_asset, manage_table, manage_resource, manage_prompt, manage_script, memory_manage and s3_object all expose reads alongside writes, and each advertises the most it can do, so a list action on one of them is still a tool a client may confirm.

These tools advertise readOnlyHint: true: platform_info, search, fetch, list_connections, platform_find_tools, show_prompts, show_scripts, api_discover, graphql_discover, s3_list, trino_query, trino_explain, trino_browse, trino_describe_table. Everything else advertises readOnlyHint: false.

A tool proxied from an upstream MCP server through the gateway toolkit carries the upstream's own annotations unchanged, including none at all where the upstream publishes none. The two toolkits built on the mcp-trino and mcp-s3 libraries take their annotations from those libraries, and an operator can override them per deployment under a toolkit's annotations: key.

The admin API returns the same object. GET /api/v1/admin/tools/schemas carries annotations on each tool and GET /api/v1/admin/tools/{name} carries it on the detail, both read from the server's own tools/list after any override, with the MCP keys unchanged and the key absent for a tool that states none. The portal's Tools page shows them as badges on the tool's header: read-only, additive for a write that states destructiveHint: false, destructive for any other write (an unstated destructiveHint is true by the specification's default), idempotent, and no hints for a tool that advertises none. That is where an operator checks that an annotations: override took effect without opening an MCP client.


Trino Tools

trino_query

Execute a read-only SQL query against Trino. Write operations (INSERT, UPDATE, DELETE, CREATE, DROP, etc.) are rejected with a clear error directing users to trino_execute.

Annotated with ReadOnlyHint: true so MCP clients can auto-approve calls to this tool.

Parameters:

Parameter Type Required Default Description
query string Yes - SQL query to execute (read-only)
limit integer No 1000 Maximum rows to return
connection string No default Connection name to use

Example:

Show me the top 10 customers by revenue

Tool call: trino_query with query SELECT customer_id, SUM(amount) as revenue FROM orders GROUP BY customer_id ORDER BY revenue DESC LIMIT 10

Response includes:

  • Query results as formatted table or JSON
  • Row count and execution time
  • Semantic context (if enabled): the catalog's description, owners, tags, glossary terms and open incidents for the table

A failed query says whose problem it is (#2032). The text is Trino's own, and structuredContent.error classifies it, so a client can tell "fix the SQL" from "the source is down, try again later" without reading prose:

{"error": {"code": "trino_query_failed", "category": "upstream_unavailable", "retryable": true,
  "message": "EXTERNAL: The connection attempt failed.",
  "trino": {"error_type": "EXTERNAL", "error_name": "JDBC_ERROR", "error_code": 65536, "sql_state": "08001", "http_status": 200},
  "transport": null}}

category is upstream_unavailable (Trino, the source behind it, or the network between), client_input (the statement itself: bad SQL, a missing table, a constraint violation) or internal. A failure where Trino never answered carries transport (kind is timeout, connection_refused, connection_reset, dns, network or http_status) and trino: null. trino_execute answers a failure the same way. A managed script records a run ended by a retryable failure as the upstream's.


No statement points a table, a schema or a partition at one of the platform's own buckets (#2616). A catalog that registers tables over managed resources holds credentials that reach the resource bucket by design, so trino_query, trino_execute and trino_explain (and a script's platform.query and platform.execute, which call them) refuse, on every connection and for every persona, administrators included, a statement holding a location in the portal bucket or the managed-resource bucket: external_location or location on CREATE TABLE or CREATE SCHEMA, ALTER TABLE ... SET PROPERTIES, CALL ... register_partition or register_table, ALTER TABLE ... EXECUTE add_files, and the same statement run by EXECUTE IMMEDIATE. A location the platform cannot read before the statement runs is refused too: one assembled from parts (||, concat, a cast, a U&'...' string), a procedure argument that is not a literal, and CREATE TABLE ... (LIKE ... INCLUDING PROPERTIES), which takes another table's location. The platform's own registrations run apart from the tools and are unaffected; manage_table is the way to put a table over a stored file. An external table over a platform bucket created before this release keeps reading until it is dropped.

trino_execute

Execute any SQL against Trino, including write operations (INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, etc.). Use this tool for data modification.

Annotated with DestructiveHint: true so MCP clients will prompt for user confirmation.

read_only is set per instance, and the block applies to the connection the call names — or to the default connection when it names none. A call routed to an instance with read_only: true is refused; the other instances of the same toolkit still accept writes.

Parameters:

Parameter Type Required Default Description
query string Yes - SQL query to execute
limit integer No 1000 Maximum rows to return
connection string No default Connection name to use

trino_explain

Get the execution plan for a query without running it.

Parameters:

Parameter Type Required Default Description
query string Yes - SQL query to explain
connection string No default Connection name to use

trino_browse

Browse the Trino catalog hierarchy. Omit all parameters to list catalogs. Provide catalog to list schemas. Provide catalog and schema to list tables (with optional pattern filter).

Parameters:

Parameter Type Required Default Description
catalog string No - Catalog name. Omit to list all catalogs
schema string No - Schema name. Requires catalog. Omit to list schemas
pattern string No - LIKE pattern to filter tables (only when listing tables)
connection string No default Connection name to use

trino_describe_table

Get detailed information about a table including columns, types, and statistics.

Parameters:

Parameter Type Required Default Description
table string Yes - Table name (can be catalog.schema.table)
connection string No default Connection name to use

Response includes:

  • Column names and data types
  • Nullable constraints
  • Partition information
  • Semantic context (if enabled): the catalog's description, owners and tags

trino_export

Export query results directly to a portal asset file, bypassing the LLM token budget. Use this after validating the query shape with trino_query using a small LIMIT. The full result set is formatted and written to S3 as an immutable portal asset. Only metadata (asset ID, URL, row count, size) is returned to the agent — not the data.

Requires portal to be enabled with S3 storage configured. Requires explicit persona authorization (not inherited from trino_query access by default).

Parameters:

Parameter Type Required Default Description
sql string Yes - SQL query to execute (read-only enforced)
format string Yes - Output format: csv, json, jsonl, markdown, parquet, or text. parquet writes every column as its own Trino type (DECIMAL, TIMESTAMP to the microsecond, ARRAY, MAP, ROW included), ZSTD-compressed, and registers with manage_table as those types; a timestamp with a time zone is kept as its instant in UTC, a timestamp finer than a microsecond is written to the microsecond, and a type Parquet has no form for (TIME, UUID, JSON, an interval) is written as text keeping what its own type carries, a zoned time's offset included. The result's message names every column any of that applies to, at any depth. A column name a table cannot declare -- one holding a comma, one outside ASCII, one edged with whitespace, two one apart by case, or a ROW field name outside letters, digits, _, ., $ and spaces -- is refused before the file is written, naming it, rather than written into a file no registration accepts. jsonl is one JSON object per row: line breaks, backslashes and nulls survive it, a nested value is written as its JSON text, and a registered table types its columns from the values
name string Yes - Display name for the exported asset (max 255 chars)
connection string No default Trino connection name
description string No - Description of the exported asset (max 2000 chars)
tags array No [] Tags for categorization. Lowercase kebab-case, max 50 chars each, max 20 tags. Tags starting with _sys- are reserved for system use.
limit integer No deployment max Maximum rows to export. A value over portal.export.max_rows is refused; the tool description states this deployment's cap. A result your limit cuts is written and flagged
on_truncation string No see below fail or warn. What a result cut at a row limit does: fail writes nothing, warn writes the rows that fit and flags the response, the asset and its version. Defaults to warn when your limit cut it and fail when the deployment cap did. See Exports cut at a limit
expect_rows integer No - The exact row count the export must write; any other count fails, or is flagged under on_truncation: "warn"
expect_min_rows integer No - The fewest rows the export may write. Mutually exclusive with expect_rows
idempotency_key string No - Client-supplied key to prevent duplicate assets on retry
timeout_seconds integer No deployment default Query execution timeout in seconds
create_public_link boolean No false Generate a public share link for the exported asset. Useful for automation pipelines that need a shareable URL.
resource object No - Land the result in a managed resource at {"path": ..., "filename": ...} instead of a new asset, create-or-replace by path. Mutually exclusive with idempotency_key and create_public_link. See Landing a response in a managed resource; api_export and graphql_export take the same block.

Response includes:

  • Asset ID and portal URL, or the landed resource's reference, URI and version
  • Public share URL (if create_public_link is true)
  • Format, row count, and file size in bytes
  • truncated, limit_applied, limit_source (request or deployment) and limit_unit, and arbitrary_subset when a cut result's SQL has no top-level ORDER BY (see Exports cut at a limit)
  • No query data (data is written to S3, not returned through the LLM)

The response is one JSON object, returned as the tool's structured result and as a single text block. The platform's call_reference is merged into the same object, so a client that reads only the structured result sees asset_id, portal_url, row_count, size_bytes and call_reference together, as it does for api_export.

Security features:

  • SQL runs through the same read-only interceptor as trino_query
  • CSV cells hold each value exactly as the query returned it. A value starting with =, +, - or @ is not rewritten, because a stored CSV is a data file that tables are registered over and scripts read
  • Sensitivity tags inherited from source datasets (PII, confidential, etc.) are automatically applied as _sys-classification:* tags
  • Hard row and byte caps enforced per deployment; a result the row cap cuts is refused unless on_truncation is warn, and a written one carries the _sys-truncated tag
  • No asset record created unless the S3 write fully succeeds

trino_list_connections

List all configured Trino connections.

Parameters: None


Catalog Reads and Writes

The platform's own catalog is read through search and fetch, and curated through apply_knowledge. There is no separate catalog toolkit; the datahub_* tools are gone, not aliased. What each kind of read and write is now:

To Use
Find datasets and definitions search (sources catalog and governance)
Read a dataset: columns and their curation, curated queries, context documents, lineage fetch mcp:dataset:(kind,connection,path)
Read lineage fetch on the dataset: its lineage within three hops upstream and downstream
Read a glossary term, node, tag, domain or data product fetch mcp:glossary_term:<id> and the like
Browse fetch mcp:glossary_node:<id> lists the terms and nodes under a node; fetch mcp:connection:(kind,name) lists the top of a connection's catalog, and a dataset reference whose path stops above a dataset (mcp:dataset:(trino,<connection>,<catalog>)) lists what is under it
Create, update or delete curation and definitions apply_knowledge with sink=catalog, and the portal's Catalog section
List connections list_connections

S3 Tools

Two tools cover object storage (#1591): s3_list is a listing, of buckets or of one bucket's objects, and s3_object is one action over a (bucket, key). Both are registered on every S3 connection. Whether a connection accepts writes is its read_only setting: put, copy, delete and an upload link (presign with method: PUT) on a read-only connection are refused with an error that names the connection, while get, metadata and a download link (presign with method: GET) still work. read_only is enforced by the toolkit only; for defence in depth, give a read-only connection upstream credentials that can only read and list, so no path writes even if a handler missed the check. A connection that sets bucket_prefix serves only the buckets whose name starts with it: s3_list with any other bucket, and every s3_object action on one (or a copy whose dest_bucket is one), is refused naming the connection and the prefix.

The platform's own buckets are refused on every connection (#2616). The portal bucket (portal.s3_bucket: assets, exports, script outputs, site releases) and the managed-resource bucket (resources.managed.s3_bucket: the resource library, webhook events, registered tables) are never served by s3_list, s3_object or a bucket source (manage_resource extract from_bucket, manage_site publish from_bucket), for any persona, administrators included: what they hold has an owner and a scope only the platform's own surfaces check. Every s3_object action naming one, either side of a copy, and s3_list naming one are refused before any request reaches the store, with the platform surface that reads it instead (fetch or manage_resource for a resource, platform.read_resource from a script, fetch or manage_asset for an asset, manage_site for a site), and a listing of a connection's buckets leaves them out. The name is compared case-insensitively, trimmed, and an ARN by its last segment, so a spelling the store could read as the bucket is refused too. The platform's own stores reach their buckets through clients of their own and are unaffected. At startup the platform lists the buckets of each S3 connection a persona may read buckets through, apart from startup, and logs s3_connection_reaches_platform_bucket for one of the platform's buckets and s3_connection_reaches_backup_bucket for a bucket named for backups (backup, backups, bak, pgbackrest, barman, velero, wal-g as a word of the name): the tools refuse the first and not the second, and the credentials reach both. Give such a connection credentials scoped to business buckets.

s3_list

List the buckets of a connection, or the objects in one bucket.

Parameters:

Parameter Type Required Default Description
bucket string No - Bucket to list objects in. Omit it to list the connection's buckets (filtered by the connection's bucket_prefix when it sets one). A bucket outside that prefix is refused.
prefix string No - Only objects whose key starts with this prefix
delimiter string No - Groups keys at this character; / lists one folder level, and the groups come back as common_prefixes
max_keys integer No 1000 Objects per page (1-1000)
continuation_token string No - The next_continuation_token of the previous page
connection string No default Connection name to use

Response includes:

  • Without a bucket: buckets (name, creation date) and count
  • With a bucket: objects (key, size, last modified, ETag, storage class), common_prefixes, count, is_truncated, next_continuation_token
  • Semantic context (if enabled): matching catalog datasets for the bucket and prefix

s3_object

Act on one object.

Parameters:

Parameter Type Required Default Description
action string Yes - get, metadata, put, copy, delete or presign
bucket string Yes - Bucket holding the object
key string Yes - Object key
content string put - Content to upload, as text or as base64 when is_base64 is true
content_type string No application/octet-stream MIME type of an uploaded object
is_base64 boolean No false The content is base64-encoded binary
metadata object No - Custom metadata to attach on put; on copy, replaces the source's metadata
dest_bucket string No the same bucket Destination bucket of a copy
dest_key string copy - Destination key of a copy
method string No GET GET for a download link, PUT for an upload link (presign)
expires_in integer No 3600 Seconds until a presigned URL expires (maximum 604800)
connection string No default Connection name to use
Action What it does Response
get Downloads the object, bounded by the connection's max_get_size content (text as-is, binary as base64 with is_base64 true), size, content_type, last_modified, etag, metadata
metadata Reads the record without the content size, content_length, content_type, last_modified, etag, metadata
put Uploads content, bounded by max_put_size size, etag, version_id
copy Copies the object to dest_key in dest_bucket source_bucket, source_key, dest_bucket, dest_key, etag
delete Removes the object deleted: true
presign Signs a URL for method that expires after expires_in seconds, against public_endpoint when the connection sets one url, method, expires_in_seconds, expires_at

get and metadata carry semantic context (if enabled) when the object is cataloged.


Knowledge Tools

Full Documentation

For the complete knowledge capture workflow including governance, lifecycle, and configuration, see Knowledge Capture.

memory_capture

The one way to record knowledge. The type (sink-class) is the single organizing axis and drives routing: personal_preference and episodic_event are live for the capturer immediately; business_knowledge, schema_entity, and operational_rule are recorded as pending and reviewed before promotion to a shared catalog via apply_knowledge. Lives in the memory toolkit so creating memory never requires the knowledge toolkit.

Capture is recall-first and returns without waiting on the embedder (#1987). The record is stored at once and its embedding is queued. When the vector is written, a similarity check runs over the caller's own memory (superseded rows excluded; stale rows stay matchable, since a restatement corrects them). Of two records at or above the supersede threshold (0.9 cosine), the newer supersedes the older. Matches in the 0.75-0.9 band are written to the new record's metadata as similar_existing (id + score), so the agent can consolidate instead of keeping a near-duplicate. The response's recall_check is pending when that check will run, or unavailable when no embedding provider is configured. schema_entity carries entity_urns and optional suggested_actions (the catalog-change payload apply_knowledge later applies).

Parameters:

Parameter Type Required Default Description
type string Yes - Sink-class: personal_preference, episodic_event (both live), business_knowledge, schema_entity, operational_rule (reviewed)
content string Yes - Knowledge to record (10-4000 chars)
entity_urns array No [] Related catalog references (mcp:dataset:(kind,connection,path), schema_entity); max 10
suggested_actions array No [] Proposed catalog changes for apply_knowledge (schema_entity)
confidence string No medium high, medium, low
source string No user user, agent_discovery, enrichment_gap
thread_ids array No [] Feedback threads this capture resolves
sources array No [] The calls this capture confirms, as the call_id (or mcp:call:<id> reference) each query and API invocation returns; max 20

Confirming the query that answered the question. Every query and API call returns its own call_id. Naming that id in sources is how an agent says the statement was worth running: the call record becomes satisfied, with the capture as its route, and enters the review queue for promotion to the catalog. It is the only route for the common case where the answer went into the conversation and was never saved as an asset. It deliberately costs a description rather than a checkbox: a rating that cost nothing would be applied to everything.


The universal, topology-free discovery entry point. Call it FIRST: one query fans across every searchable source the caller can access and returns results grouped by source with a coverage summary, so the agent sees the shape of the answer space instead of tunneling into the first tool that comes to mind. Walking the catalog's structure is fetch on a connection, a partial dataset path or a glossary node; the scoped API drill-down stays in api_discover.

knowledge_search was renamed to search

The #632 read-path tool knowledge_search was renamed to search in #645 and its corpus widened to include API endpoints and connections.

Corpus (everything the persona can access): the platform catalog's datasets, its governance vocabulary (glossary terms and nodes, tags, domains and data products), canonical knowledge pages (the internal-knowledge home for business/domain ontology, searched over their full markdown content), the caller's personal memory, captured insights, the caller's feedback threads, saved assets, the caller's own recorded calls (the queries and API invocations they have already run, each hit carrying its outcome and how many later sessions re-ran it, so a proven statement outranks a guess), the caller's own sessions (found by what their calls said they were for and by the names of what they saved, so "what did I do about churn last week" reaches the work rather than only the individual calls inside it), managed resources (human-uploaded reference material, searched over their metadata and their extracted file content), prompts, managed scripts, API endpoints (aggregated across every API gateway connection, reusing the per-connection semantic ranking of api_discover), and connections. Memory, insights, assets, recorded calls, sessions, and managed scripts are per-user, scoped server-side to the caller, so a search never surfaces another user's private records; the catalog, the governance vocabulary, knowledge pages, prompts, endpoints (each gateway applies its own route policy), and connections are shared. Resources are visibility-scoped: global material reaches every caller, persona material only its members, and user (personal) material only its owner, exactly as resources/list computes it. A managed script is its owner's alone. Persona visibility is membership (derived from the caller's roles), not the persona the request resolved to — resolution falls back to the configured default persona for a caller whose roles match none, and that fallback must not hand them the default persona's material. A caller with no identity still sees shared sources but no per-user data. API endpoints and connections are in the default corpus, not behind an opt-in.

Connection boundary. The three topology sources (catalog, connections, endpoints) are additionally narrowed to the connections the caller's persona is granted by its connections.allow rules — the same predicate that authorizes a tool call, so what a caller can find and what a caller can call never disagree. A catalog dataset belongs to the connection its reference names, and is visible only when the persona reaches that connection. Removals are reported, never silent: each coverage entry carries a withheld count and the response carries a withheld_notice naming the persona and the remedy, so a shortened result set reads as "present, but not yours to see" rather than "does not exist". See Persona Connection Access.

Catalog descriptions are semantically searchable. The catalog source ranks two ways. The platform keeps its own index of every catalog dataset's text (name, description, tags, domain) and ranks the query against it first; the catalog's word search over names, paths and descriptions follows as the recall tail. This is what makes a fact written into a dataset's description, by apply_knowledge or in the portal's Catalog section, reachable from a topical query that shares none of its words and names none of its entities. A dataset both find is shown once. The index is refreshed by a background index job (a dataset declared or curated since the index last read it is re-read on the index queue's next check, and the whole catalog at least every knowledge.catalog_index.sync_interval) and appears on the admin Indexing dashboard as the catalog-datasets kind; it holds a discardable copy of catalog text, so every hit is still dereferenced against the catalog itself. Without an embedding provider the index ranks lexically.

The governance vocabulary is a source, not a dataset attribute. A glossary term, a glossary node, a tag, a domain and a data product are searchable and fetchable entities in their own right (#1160). The governance source answers "what does X mean here" and the catalog source answers "which datasets are about X"; they are siblings so a definition is never crowded out of the display budget by a broad dataset match. A hit carries the definition in its text, so a term match is useful without a follow-up fetch. Definitions are ranked by the words the question shares with each one's name, slug and description, the same token-overlap rule the connections source uses, so a natural-language question reaches a term whose name it does not repeat exactly.

Connection boundary for governance entities. A definition belongs to no connection, so it is visible to every caller who may search. The datasets and columns listed under a tag, domain or term by fetch belong to connections and are filtered by the caller's boundary, with the removed count and the reason carried on the fetched entity.

Resource links. A hit backed by an uploaded file additionally carries an MCP resource_link content block with the resource's canonical mcp:// URI, name, description, and MIME type, so a client with native resource support can attach the file itself rather than only the pointer the model dereferences.

Balanced result set. Rather than one flat relevance list (which lets one strong source dominate), the display set is built from a total budget with a per-source floor (so every matching source stays visible), a per-source ceiling (so none runs away), and redistribution of unused budget to the sources with more relevant hits. Every response also carries a coverage summary of per-source matched vs shown counts, so the agent learns where the answer space lives even when only the top few of each source are displayed. Hits are navigational snippets (title, ref, reference, short context line, source); the agent reads the full content with fetch (any source) or drills in with a scoped tool (trino_query, api_invoke_endpoint).

A query may be text (intent), entity-keyed (entity_urns, returning every source linked to those datasets and their lineage neighbors: the catalog entity, URN-linked insights, and your URN-linked memory), or both. Ranking is hybrid (semantic vector + lexical) when an embedding provider is configured and lexical-only otherwise; an entity-only query reports ranking entity. The response carries a ranking field, a count (total hits shown), a groups array (each {source, hits[]} where every hit pairs the matched text with its source, a ref, a relevance score, and where present status, entity_urns, and dimension), and a coverage array ({source, matched, shown, matched_capped, withheld}, where withheld is present only when the persona connection boundary removed matches, alongside a top-level withheld_notice).

What bounds one source (matched_capped). Each source ranks up to limit candidates, or 25 when limit is smaller, and matched counts within that bound. A source with more matching records than the bound carries matched_capped: true, which makes its matched a floor rather than a total ("at least 25", not "25"). Without the flag the two states were one number: a source that matched exactly the bound and a source that matched thousands both read as matched: 25, shown: 25, which tells the agent it has seen everything at precisely the moment it has not (#1585). Raise limit to rank further into one source, or browse it for a true total. A count the persona connection boundary shortened is exact for what it says -- matched is what the caller may see and withheld carries the rest -- so a withheld-shortened source is not flagged.

Parameters:

Parameter Type Required Default Description
intent string Conditional - Natural-language description of what you are looking for. Provide intent, entity_urns, or both
context string No - Optional surrounding context, folded into the intent to sharpen relevance
entity_urns array Conditional - Exact entity-keyed lookup: everything linked to these catalog references (mcp:dataset:(kind,connection,path): the catalog entity, insights about it, and your memory linked to it), expanded along lineage
status string No - Optional filter by insight review status (pending, approved, rejected, applied, superseded)
sources array No - Narrow the search to named sources (catalog, governance, knowledge_pages, memory, insights, feedback, assets, resources, prompts, scripts, calls, sessions, endpoints, connections). Only narrows; never opts into a source the persona could not otherwise access. An unrecognized name is echoed back in the response unknown_sources rather than silently ignored
limit integer No 10 Total results to display across all sources (max 50). Also the per-source ranking depth when it exceeds 25, so a search narrowed to one source returns up to limit from it

search browse mode (enumeration)

search is relevance-ranked and floors/caps each source, so it cannot list a source in full. Browse mode (#695) is the exhaustive counterpart: it pages the complete set of one source with a total count and no relevance threshold, so an agent can audit, dedup, govern, or migrate a corpus it must first obtain in full. It is the same search tool, not a new one.

A call enters browse mode when it carries exactly one sources entry, no intent, and no entity_urns; pass offset to page. The browsable source is knowledge_pages. The catalog is walked with fetch instead: a connection lists the top of its catalog and a glossary node lists what is under it. Browsing more than one source at once, a non-browsable source, or an unknown source is a tool error that names what can be browsed.

Parameter Type Required Default Description
sources array Yes - Exactly one source to enumerate (knowledge_pages)
offset integer No 0 0-based start of the page
limit integer No 50 Page size (max 100)

The response is a flat, unranked page: {source, total, offset, limit, count, items[]}, where total is the source's full member count -- exact, never capped, which is why browse is the way to enumerate a source (so the agent knows how many pages remain) and each item carries the same reference that fetch reads in full. Scope mirrors search: knowledge pages are org-global, so any caller may enumerate them; a per-user source is never browsable for an anonymous caller.


fetch

The companion read verb to search. search returns navigational pointers with truncated snippets; fetch dereferences one pointer's reference back to its complete content, so the agent reads in full what it found. It is the single consumer of the reference every search hit already carries, and it collapses the previously fragmented scoped readers (the catalog's by-reference reads, manage_asset get, manage_prompt get) into one verb. Registered alongside search.

Every reference is in the mcp: scheme. fetch routes each well-formed reference by its form to the owning source:

Reference form Source Returns
mcp:knowledge_page:<id> knowledge pages the full markdown body, and the page's declared references as references to follow. A cited asset, collection, prompt or managed resource the caller cannot open is left out and counted in references_withheld without naming it, by the rule the portal's page references apply (#2028). The page's slug works in place of its id, which is what makes a page referenceable from text the platform ships: a built-in page's id is generated on each deployment at reconcile time, so only the slug is the same everywhere. An id is resolved first, so a slug can never shadow a page asked for by id
mcp:asset:<id> assets the asset's metadata record (blob bytes stay in S3, reached with s3_object get or presign), for the caller who owns it, a person it is shared with directly or through a collection, and an administrator, as the portal opens it (#2027); a script run reaches its author's own assets and its script's outputs
mcp:resource:<id> resources the resource's metadata record, plus its contents in whichever form the file admits (see below). Only a file over 1 MB returns metadata alone, with its canonical mcp:// URI, MIME type, and size
mcp:prompt:<id> prompts the full prompt
mcp:script:<id> scripts the managed script's contract (name, description, owner, typed parameters, whether a run would be admitted, schedule), the files it produced that the caller can open with a count of the rest, and its current source, for every signed-in caller. The last successful run and the saved state are included for the script's owner and administrators. Citable on a knowledge page, where it resolves for every reader
mcp:connection:(kind,name) connections the connection descriptor, with the top level of its catalog: the Trino catalogs (or the operations) it holds, each with the reference that fetches further down
mcp:dataset:(kind,connection,path) catalog a dataset in the platform's own catalog: its type, the schema document it was read from, its columns in order with each column's curation, and its description, tags, glossary terms, owners, domain, custom and structured properties, documentation links, curated queries, incidents and context documents; its lineage within three hops upstream and downstream; and, for a table or view, query_availability (whether it is queryable, the table to query, the connection, the estimated row count). A dataset reference whose path stops above a dataset (mcp:dataset:(trino,<connection>,<catalog>) or (...,<catalog>.<schema>)) lists the path groups and datasets under it instead. The connection is named by its instance; the path is the dotted name the connection uses (catalog.schema.table for Trino), with a segment holding ., ,, (, ) or " double-quoted as SQL quotes an identifier. Readable only by a caller whose persona may reach the connection
mcp:column:(kind,connection,path,column) catalog one column of a catalog dataset, with its curation and its dataset's reference, under the same connection rule
mcp:schema_document:(kind,connection,filename) catalog an API connection's OpenAPI spec or a GraphQL connection's SDL as the catalog scan last read it, as text up to 1 MiB, under the same connection rule
mcp:glossary_term:<id>, mcp:glossary_node:<id>, mcp:domain:<id>, mcp:data_product:<id> governance the definition's name, parent node, description, owners, custom properties and documentation links; a node with the terms and nodes under it, and a term or domain with the datasets and columns that carry it. The slug works in place of the id; the id is what is stored, so renaming one changes no reference to it
mcp:tag:<name> governance the tag, its description, and the datasets and columns that carry it
mcp:insight:<id> insights the full captured insight (scoped to the caller; fetch-only, not citable on a page)
mcp:memory:<id> memory the full personal memory record (scoped to the caller; fetch-only, not citable on a page)
mcp:call:<id> calls the recorded call: the statement or request that ran, the purpose stated for it, what came of it, and how many later sessions re-ran it. Reading one is also what makes your own re-run of it count as reuse (scoped to the caller; fetch-only, not citable on a page)
mcp:session:<id> sessions the session the caller ran: its summary, the assets and insights it produced as references to follow, and its call timeline in order — each call with the purpose stated for it, and, where the catalog recorded one, its kind, its outcome, and the mcp:call: reference that reads it in full (scoped to the caller; fetch-only, not citable on a page)

The usual source of a reference is a search result's reference field, but fetch is not limited to references search produced: a well-formed reference held from another tool works too (for example a lineage edge's source or target from a fetched dataset). Feedback threads and API endpoints emit no reference and are not fetch targets.

A fetched asset or resource that has been registered as a query-engine table also carries tables: one entry per registration over the file, newest first, each with registration_id, connection, query_table, columns, sample_sql, stale, follow, repair and follow_error -- the same facts, in the same order, manage_table action=list reports under table_registrations. The columns are carried so the query can be written without a second call. A file registered twice is two entries and the document names both. A search hit carries one table instead and without the columns -- a hit is a pointer chosen from a ranked page of them -- and it is the newest registration whose follow_error is empty, so a hit points at a table a follow has not disowned; when every registration carries one, the hit has no table and the document is where the reasons are.

A fetched governance entity fills content with {reference, kind, name, description, parent?, owners?, custom_properties?, documentation_links?, children?, carriers?, more_carriers?, carriers_withheld?, notice?}. A node's children lists up to 200 entries under it. The carrier list is bounded (25); more_carriers reports that more carry it than are listed, so a bounded list never reads as the whole membership. carriers_withheld and notice report what the caller's connection boundary removed from the list, so a short list is never mistaken for an unused term.

Scope mirrors search exactly: the per-user sources (assets, your memory, your insights, your recorded calls, your sessions) are read only for the identity that owns the record, a persona/personal-scoped prompt only for the matching caller, a managed resource only for a caller whose visible scopes include it, and a managed script only for its owner, so fetch never returns content the same caller could not have found with search. The connection boundary applies here too: a dataset reference or connection reference belonging to a connection the caller's persona is not granted is reported as not-found, so a citation cannot read around what search omitted. A reference outside the caller's scope is reported as not-found, indistinguishable from a missing one, so existence does not leak.

Parameters:

Parameter Type Required Default Description
reference string Yes - A search result's reference, passed exactly as search returned it

The response is {found, reference, document?, message?}. A resolved reference returns found: true with a document ({reference, source, title, body?, content?, entity_urns?}, where text-bodied sources fill body and structured sources fill content with the source-native payload). A stale, unknown, or out-of-scope reference returns found: false with an explanatory message, a structured not-found, not a tool error, so a dangling citation is a normal answer. A malformed call (empty reference) and a real backend failure are tool errors.

A catalog or governance reference the catalog does not hold is one of those not-founds. Whether the record is documented has no bearing on it, so a dataset with no description and a glossary term at the root with no definition resolve like any other. The same read backs search by entity_urns, so a reference that names nothing reports no match rather than a hit whose reference then fails to fetch (#1605, #1610).

What a fetched file comes back as

A managed resource is a file someone uploaded, so fetch returns it in whichever form the file admits rather than describing it. One file, one form:

The file What fetch returns
Text (markdown, CSV, JSON, SQL, source, SVG) its contents in document.body
PDF its extracted text in document.body
An Office document (.docx, .xlsx, .pptx, .odt, .ods, .odp) or any zip its text parts in document.body, each under a banner naming the part and its size, followed by an inventory of the members that were not rendered. An Office document is a zip of XML parts, and the parts are what an agent reproducing it as a template needs
An image an MCP image content block, which a model looks at directly
Anything else an MCP resource content block carrying the bytes, for the client's own tools to open

A file above the 1 MB inline limit is the one case that returns metadata alone. Its record carries the canonical mcp:// URI, which resources/read reads the whole file by with no limit.

The same reader feeds the content index, so a PDF and a presentation are found by search on words that appear only inside them, not on their filename alone. A picture is indexed on its metadata: there is no text in it to find.

PDF text is read by PDFium compiled to WebAssembly and run under wazero. It is cgo-free, and its module is compiled on the first PDF a deployment is asked to read rather than at startup, so a deployment that never meets one pays nothing for it.


Knowledge pages (canonical business/domain knowledge)

Knowledge pages are the platform's canonical store for business and domain knowledge (the sibling of the catalog's curation), authored as markdown in the portal. The provisional "draft" of knowledge is the memory/insight inbox; a page, once it exists, is canonical. They are a distinct, org-shared entity (not owner-scoped portal assets): the markdown body is stored inline in Postgres so page content is semantically searchable, and pages surface in the unified search tool under the knowledge_pages source. Threads/feedback attach to a page (target_type=asset reuse is planned; native attach lands with the threads phase).

Governance: every authenticated user can read pages; create/edit/remove is gated to personas with apply_knowledge access (the same authorization that lets a persona apply everyone's captured insights), so no separate curator role is introduced.

REST API (/api/v1/portal/knowledge-pages), mounted with the portal handler:

Method Path Access Description
GET /knowledge-pages any user List pages (filter by tag, q, paginated)
GET /knowledge-pages/search?q= any user Relevance search over page content (hybrid when an embedding provider is configured)
GET /knowledge-pages/{id} any user Get a page
GET /knowledge-pages/{id}/versions any user List version history
GET /knowledge-pages/graph any user The corpus as typed nodes and edges for the portal's graph view (filter by tag, limit)
POST /knowledge-pages apply_knowledge Create a page
PUT /knowledge-pages/{id} apply_knowledge Edit a page (snapshots a new version)
DELETE /knowledge-pages/{id} apply_knowledge Soft-delete a page

Embeddings are produced off the request path by the shared indexjobs queue (source_kind=portal-knowledge-pages). Creating a page, and editing one in a way that moves its indexed text, enqueues that page's own index job at write time (trigger write), so it enters ranked search in roughly the time one embed takes rather than on the next reconciler sweep; the edit also clears the page's vector in the same transaction, so it never ranks semantically on its old content. The reconciler remains the backstop for what a write could not produce: a page saved or edited while the embedding provider was unavailable, a provider model swap, and chunk rows lost outside the write path (a database restore, or a manual prune).

The graph read returns the pages plus every entity their references point at, as one access-filtered response, so a client draws the whole corpus without an N+1 sweep of the per-page refs endpoint. An entity the viewer cannot access is absent from it entirely — neither node nor edge — the same visibility rule the per-page refs and backlinks reads apply. Both the page window and the total node count are capped, and either cap is reported in the response (truncated plus a human-readable notice) rather than applied silently.


apply_knowledge

Review, synthesize, and apply captured insights to their canonical home. Admin-only. Requires knowledge.apply.enabled: true.

apply_knowledge is the sink router (#633): the apply action's sink decides where a capture is promoted.

  • sink: catalog (default) writes the changes to an entity of the platform catalog (entity_urn).
  • sink: knowledge_page promotes a capture to a canonical portal knowledge page, found-or-created by page.slug (so repeated promotions on the same slug consolidate into one living page). The capture-time sink-class is a non-binding hint: any insight can be promoted to either sink, with the destination chosen at apply (prefer the catalog for entity-anchored facts, a page for broader business or domain knowledge).
  • sink: agent_instructions promotes an operating rule into the deployment's customized agent instructions.

Both sinks record a changeset (page promotions use target_urn = "kp:<slug>") listed by list_changesets and reversible by rollback. Rolling back a page promotion soft-deletes a newly created page or restores a prior version, and is refused if the page was edited after the promotion.

entity_urn is a catalog reference: mcp:dataset:(kind,connection,path), mcp:column:(kind,connection,path,column), mcp:glossary_term:<id>, mcp:glossary_node:<id>, mcp:domain:<id>, mcp:data_product:<id> or mcp:tag:<name>. Any other form, a urn:li: URN included, is refused with the forms it takes.

The changes of one apply are written in one transaction: a change that cannot be applied writes nothing, including the changes before it. Every change type is undone by rollback, which restores each aspect the apply wrote to the value it replaced, through the catalog's own history. A rollback is refused, and reverts nothing, when an aspect the changeset wrote has been written again since (roll that change back first), when the changeset created a prompt, and when the changeset was written before the platform catalog held its entities (an apply from a release that wrote to DataHub), whose writes are not in the catalog's history.

Without a platform catalog (no database, or semantic.provider: noop), catalog changes are refused with an error naming sink: knowledge_page as the destination that needs none. The add_prompt change type creates a platform prompt rather than writing to the catalog and therefore still applies.

Citing entities on a page. To attach an entity reference (a catalog entity's mcp:dataset:/mcp:column:/mcp:glossary_term: reference and the like, or an mcp:asset/mcp:prompt/mcp:collection/mcp:connection/mcp:knowledge_page/mcp:script) to a page, pass it in page.references or write it in the body as plain text or a markdown link. A reference wrapped in backticks or a fenced code block is treated as a documentation example and intentionally ignored, so a backticked URN produces no reference and no link. Each entry in page.references is existence-checked before the page is written: a missing internal (mcp:) entity rejects the apply, and a reference in any other form is refused. References in page.references and those carried from the source insights attach with the promotion (so a rollback undoes them); a stale insight-carried reference is skipped rather than blocking. A target cited both in page.references and inline in the body is stored once. Inline body references are also filtered to those that exist, so a stale mcp: token in prose is skipped rather than blocking the page or leaving it partially written. A dropped insight-carried or inline-body reference is not silent: the apply response reports the dropped targets in references_dropped (a reference whose target was deleted, or an insight-carried reference that is not citable on a shared page such as mcp:memory:/mcp:insight:) and always reports the count of references that landed in references_attached, so an agent can reconcile what it cited against what was attached and fix the payload or the prose. A managed script (mcp:script:<id>, #1855) is citable too, so a page that says which script maintains a dataset can cite the script itself: the citation is existence-checked like any other internal reference (a script that does not exist, or an id that is not a UUID, rejects the apply). A script's definition is readable by everyone signed in (#2027), so the citation resolves for every reader, and fetch on the reference returns the script's contract and source to every reader. Deleting the script removes the citation.

operational_rule is stored as a knowledge page like business_knowledge (it is canonical knowledge tied to no catalog entity); active enforcement of operational rules via the rules engine is tracked separately.

Parameters:

Parameter Type Required Description
action string Yes bulk_review, review, synthesize, apply, approve, reject, rollback, list_changesets, bulk_untag
sink string No apply target: catalog (default), knowledge_page or agent_instructions
entity_urn string Conditional Required for review, synthesize, list_changesets, and apply with sink=catalog: a catalog reference
tag_urn string Conditional Required for bulk_untag; the tag (a name or mcp:tag:<name>) to remove from every dataset and column that carries it
page object Conditional {slug, title, body, summary?, tags?, references?} for apply with sink=knowledge_page. references is a list of serialized reference strings (mcp:<type>:<id>) attached to the page independent of the body
insight_ids array Conditional Source insights; required for approve, reject. On apply, pass the promoted insights so they are marked applied and the changeset is linked to them (closes the review loop; the queue then reflects what is live). Sink-class is a non-binding hint; any insight can be applied to either sink (destination chosen at apply)
changes array Conditional Required for apply with sink=catalog
changeset_id string Conditional Required for rollback
confirm bool No Required when require_confirmation is true (apply and rollback)
review_notes string No Notes for approve/reject actions
itemize bool No With bulk_review, also return the pending insights themselves (full insight_text body, captured_by, sink_class, created_at, suggested_actions_count, etc.; full suggested_actions omitted, fetch for it), paginated by offset/limit. The response is bounded (page_size_capped/by_entity_truncated flag any cut) so it stays under the output limit
limit int No Page size for itemized bulk_review (default 20, max 100)
offset int No Page start for itemized bulk_review; pass the previous next_offset to continue

Actions:

  • bulk_review: Counts of all pending insights (total_pending, by_entity, by_category, by_confidence) plus a review-queue staleness rollup (oldest_pending_at, oldest_pending_age_days, pending_over_30d, omitted when the queue is empty) so aging review debt is visible. Pass itemize: true to enumerate the queue itself, paginated, with each insight's full insight_text body, id, captured_by, sink_class, and suggested_actions_count (full suggested_actions omitted, fetch for it; the relevance-ranked search tool cannot list the queue completely). The response is bounded so it stays under the output limit: page_size_capped: true flags a short insights page (continue with next_offset) and by_entity_truncated: true flags a capped by_entity
  • review: Insights for a specific entity with its current catalog curation
  • approve/reject: Transition insight status with optional notes
  • synthesize: Structured change proposals from approved insights
  • apply: Write changes to the platform catalog in one transaction, recorded as a changeset
  • list_changesets: List an entity's changesets (id, timestamp, actor, change type, rollback status)
  • rollback: Restore every aspect a changeset wrote to the value it replaced, in one transaction, and return its source insights to the review queue as pending (requires changeset_id and confirm)
  • bulk_untag: Remove a tag (tag_urn) from every dataset and column carrying it, up to 500 per call in one transaction, recording one changeset that rollback undoes (when require_confirmation is enabled it first returns the affected count and needs confirm; a tag on more entities is cleared by calling again)

Supported change types for apply action:

A dataset and its columns carry every kind of curation; curated queries, incidents and context documents are dataset-only; a glossary term, glossary node, domain, data product or tag carries a description, owners, custom properties and documentation links.

Change Type Target Detail Writes
update_description column:<name> for a column, empty for the entity Description text description
add_tag / remove_tag column:<name> for a column, empty for the entity Tag name (pii; mcp:tag:pii is read as pii) tags
add_glossary_term column:<name> for a column, empty for the entity A term reference or name; a name the catalog does not hold creates the term, listed in created_ids glossary_terms
flag_quality_issue Ignored Quality issue description the QualityIssue tag, and on a dataset with a detail an active Data quality issue incident (one per open detail)
add_documentation URL Link description documentation_links
add_curated_query Ignored Query name (query_sql the statement, query_description optional); a query of the same name is replaced curated_queries (datasets)
set_structured_property Property name Value or JSON array structured_properties
remove_structured_property Property name Ignored structured_properties
raise_incident Incident title Description; the incident id is in created_ids incidents (datasets)
resolve_incident Incident id Resolution message incidents (datasets)
add_context_document Document title Content (query_description the category); the document id is in created_ids context_documents (datasets)
update_context_document Document id New content (query_sql the new title, query_description the category) context_documents (datasets)
remove_context_document Document id Ignored context_documents (datasets)
delete_tag Ignored Ignored entity_urn is mcp:tag:<name>: takes the tag off every dataset and column carrying it
set_custom_property Key Value custom_properties
remove_custom_property Key Ignored custom_properties
add_prompt Prompt name Prompt content a platform prompt; a changeset that created one is not rolled back

Memory Tools

Full documentation

For the complete memory layer documentation including architecture, staleness detection, and cross-enrichment, see Memory Layer.

memory_manage

Manages the lifecycle of existing persistent memory. Create new memory with memory_capture. Opt-in per persona (requires memory_* in tools.allow). Requires memory.enabled: true.

Parameter Type Required Description
command string No Operation: update, forget, list, review_stale, review_duplicates, consolidate. Omit for help. (Create with memory_capture.)
id string For update, forget, consolidate Memory record ID (for consolidate: the record to keep)
duplicate_id string For consolidate The duplicate record the kept record supersedes
dimension string No LOCOMO dimension: knowledge, event, entity, relationship, preference
category string No Category: correction, business_context, data_quality, usage_guidance, relationship, enhancement, general
confidence string No high, medium, low (default: medium)
source string No user, agent_discovery, enrichment_gap, automation, lineage_event
entity_urns string[] No Catalog references this memory relates to, e.g. mcp:dataset:(trino,<connection>,<catalog>.<schema>.<table>) (max 10)
metadata object No Arbitrary metadata (e.g., suggested_actions, superseded_by)
filter_* string No Filters for list: filter_dimension, filter_category, filter_status, filter_entity_urn
limit int No Page size for list (default 20, max 100); also caps review_duplicates pairs (may hold fewer when the byte budget hits, more_pairs=true)
offset int No Pagination offset for list (not used by review_duplicates)

Memory recall moved to search

Reading memory back (relevance, entity lookup, and lineage/graph traversal) is now part of the universal search tool. The memory toolkit retains memory_manage for the write path.


Portal Tools

The portal toolkit persists AI-generated assets (JSX dashboards, HTML reports, SVG charts) to S3 with PostgreSQL metadata, enabling viewing and sharing. Every write captures provenance — the calls the asset was built from — from the audit log. See Provenance.

Prerequisites

Portal tools require portal.enabled: true, a configured S3 connection (portal.s3_connection), and database.dsn. See Configuration.

save_asset

Save AI-generated content to the asset portal as a versioned asset. Captures the calls the asset was built from: by default every data call the session made since its last save or export, or exactly the calls you name in sources.

It is how every document is produced, in the words a user asks for it (#1995): a PowerPoint, deck or slides is an HTML presentation on the served slide runtime, which its page exports to PDF; a report, one pager, memo or "markdown report" is text/markdown or text/html; a spreadsheet is text/csv; a dashboard or chart is HTML, JSX or SVG. The tool's description, the platform_info baseline and the presentations knowledge page all say so, and that there is no local file to build, software to install, or bucket to write a document to. When the word names a format the platform does not produce, such as a .pptx, the agent says what it is making instead and makes it.

Parameters:

Parameter Type Required Default Description
name string Yes - Display name for the asset (max 255 chars)
content string, object or array Yes - The asset content as a string; for a JSON content type, the JSON object or array itself is accepted and stored as its JSON text
content_type string Yes - MIME type the asset is stored under, a text type such as text/html, text/markdown, text/csv or application/json. Any other is refused, and the refusal lists every accepted type; the schema leaves the list to the refusal so a new type does not grow every tools/list (see Accepted types)
description string No - Description of the asset (max 2000 chars)
tags array No [] Tags for categorization (max 20 tags, each max 100 chars)
sources array No session window The calls this asset was built from, as the call_id (or mcp:call:<id> reference) each query and API invocation returns. Replaces the default window; only your own calls resolve (max 100)

Response includes:

  • Asset ID for future reference
  • Portal URL for viewing (if public_base_url is configured)
  • Whether provenance was captured, and how many calls it recorded

Writing one that fits the reader's screen. A saved document is opened on a phone as readily as on a laptop, usually through a share link, and the reader with the narrow screen cannot widen it. The rules an html, jsx or svg document has to follow to reflow into it — which content type is framed by what, the viewport declaration an HTML document owes, rows that wrap, the three things allowed their own horizontal scroll box, and an SVG sized by its viewBox rather than its attributes — ship as the built-in knowledge page mcp:knowledge_page:platform-documents-that-fit-any-screen, which the instruction baseline names for every caller that can write an asset.

Writing a presentation. A deck is an HTML asset on the reveal.js runtime the platform serves at /portal/vendor/reveal/ (#1767): the paths a deck names and why they are same-origin rather than a CDN, the document skeleton with one <section> per slide, Markdown slides, brand by reference, what the Present, Overview and Export PDF controls and the thumbnail do with it, and what the sandboxed frame does not allow (the speaker view's second window) ship as mcp:knowledge_page:platform-presentations, named by the same baseline.

Drawing a chart, diagram or animation. A chart, flow, mind map, roadmap, network, graph or animation in a document is drawn by a library loaded from a public CDN, not hand-drawn SVG. The libraries (Vega-Lite, Mermaid, Markmap, D3 with d3-sankey, Frappe Gantt, Cytoscape.js, Graphviz, Rough.js and dotLottie) at pinned versions, a skeleton for each that follows light and dark and fits the frame, what changes on a slide, and the JSX form through esm.sh ship as mcp:knowledge_page:platform-visual-libraries, which the instruction baseline and save_asset's description both name (#2373).


manage_asset

List, retrieve, update, delete, or share saved assets, and edit an asset's content in place. All mutations enforce ownership (users can only modify their own assets); the read-only content verbs additionally accept a share grant, so an asset shared with you can be read but not patched.

Parameters:

Parameter Type Required Default Description
action string Yes - Action to perform: list, get, update, delete, search, patch, locate, get_content, outline, stats, diff, share, list_shares, revoke_share
asset_id string Conditional - Required for get, update, delete, share, list_shares, and every content action
content string No - New content (for update; replaces the whole body)
name string No - New name (for update)
description string No - New description (for update)
tags array No - New tags (for update), or for list and search the tags an asset must carry, every one of them
scope string No search: all, list: mine Which assets list and search cover: mine, shared (shared with you by a person, your address or your persona, directly or through a shared collection) or all
owners array No - For list and search: only assets owned by one of these email addresses
max_versions integer | null No - How many versions this asset keeps (update). Omit to leave the setting alone, null to go back to the deployment default, 0 to keep every version, N to keep the newest N. A negative value is refused. Sent with content in one call, the new cap applies to the version that call writes
content_type string No - New content type (for update, only when replacing content; same accepted set as save_asset, omit it to keep the type the asset already carries), or for list and search the content type an asset must have
change_summary string No - Summary recorded on the new version (update and patch)
sources array No session window The calls behind a content edit (update and patch), as call_id values or mcp:call:<id> references. Recorded as a new capture beside the ones earlier versions carry
query string Conditional - Free-text relevance query (required for search)
limit integer No 50 Max results for list (max 200); ranked search defaults to 20 (max 100)
offset integer No 0 Rows to skip in list and search, which page; a search pages at most 1,000 results deep
recipient string No - Who to share with (share): an email address, or a name resolved against the user directory. Omit for a link share
permission string No viewer viewer or editor (share). A link is always viewer
access_mode string No authenticated Who a link admits (share, no recipient): authenticated or public
expires_in string Conditional - Duration bounding a public link (24h). Required for access_mode: public, refused otherwise
share_id string Conditional - Share to end (required for revoke_share)

The patch and navigation arguments (edits, base_version, dry_run, find, pattern, section, selector, occurrence, line_start, line_end, context_bytes, from_version, to_version) are the shared content-editing grammar documented in Editing content in place. Inside edits, occurrence is a per-edit field; at the top level it disambiguates a selector used to scope locate or get_content.

Actions:

  • list: Page the assets you can open in scope (your own by default) with metadata, narrowed by owners, tags and content_type; total counts every match
  • get: Retrieve full asset metadata by ID (the metadata row, not the body)
  • update: Change name, description, tags, version retention, or replace the whole content. A version pushed past max_versions is deleted along with its stored content, which is what makes the setting worth reaching for on an asset a schedule rewrites; see Version retention
  • delete: Soft-delete an asset
  • search: Rank the assets you can open by relevance to query: in scope all by default (your own and those shared with you by a person, your address or your persona, directly or through a shared collection), narrowed by owners, tags and content_type before ranking. Every asset whose name, description or tags match is a result, and so is one the embedding model places near the query; total counts them all and offset pages them. Uses the same hybrid (vector + lexical) blend as the prompt and Knowledge & Memory search when an embedding provider is configured, lexical-only otherwise, reported as ranking. The set ranked is the one the per-asset access checks grant, applied in SQL, so an asset you cannot open is never ranked or counted. A managed-script run's search ranks what that script produced, whatever scope says.
  • patch / locate / get_content / outline / stats / diff: read and edit the body without moving the whole document. See below.
  • share / list_shares / revoke_share: give someone access to an asset, see who has it, and take it back. See Sharing an asset from the session.

Registering an asset as a queryable table is the separate manage_table tool, which serves an uploaded resource on the same action.

A patch writes an ordinary new version, so list_versions and revert keep working, and the version's change summary is the caller's change_summary (or a generated "3 edits via patch") instead of a fixed constant.


manage_table

Make a stored CSV, JSON-lines or Parquet file readable as a query-engine table, so trino_query can join it to warehouse tables. A JSON-lines or Parquet table returns every value exactly and declares each column's type (inferred from a JSON-lines file's values, read from a Parquet file's footer); a CSV table's columns are all VARCHAR, it cannot carry a line break inside a value, and it reads a null as an empty string (see Registered Tables). Nothing is copied or ingested: the table is an external table over the directory the file already sits in.

The file is named by its reference — the string a search hit and a fetch document carry — so one action serves every kind of stored file and there is no argument saying which kind it is. That is what closes the gap a person otherwise walks around: they upload a vendor CSV, and the agent that found it can register it in the same turn instead of sending them to the portal.

Parameters:

Parameter Type Required Default Description
action string Yes - register, list, unregister, or check
reference string Conditional - The file to act on: mcp:resource:<id> for uploaded reference material, mcp:asset:<id> for a saved asset. Pass it verbatim. Required for register and list
connection string Conditional - Trino connection whose scratch schema holds the table (required for register and check). Call list_connections to see the ones you can reach
table_name string No filename slug Name for the registered table; prefixed with your persona either way
follow boolean No true Whether the table follows the file: each revision or version written over it moves the table onto the new contents. false pins the table to the version it is registered over
repair boolean No false Save a corrected version of a file that cannot be read as a table the way it is stored, and register that. The choice is kept on the registration: a following table corrects each later version carrying the same kind of defect and moves onto the corrected version
registration_id string Conditional - Registration to drop (required for unregister). action=list reports them on the rows of table_registrations

Actions:

  • register: create the table and report its qualified name, its columns and their column_types, a sample statement, and whether it follows the file. A CSV's columns are all VARCHAR, the storage format's rule rather than a platform choice, and its sample join shows the CAST a typed column needs; a JSON-lines or Parquet table's sample is the plain join
  • list: what is registered over this file, under table_registrations. Each row carries the registration_id an unregister takes, the query_table, its columns, their declared column_types and sample_sql, who registered it, whether it follows the file, whether it has gone stale — the file has a newer revision or version than the table points at — and, for a following table that is stale, the follow_error that kept it there. Check here for an existing registration before registering: registering the same name again replaces the row and changes its id. The same registrations appear as tables on a search hit and a fetch document, projected for the caller who only wants to write the query
  • unregister: drop one table. The file itself is unchanged
  • check: whether a table could be registered on connection, with nothing written (#2213): the connection is granted to your persona, has a scratch catalog and schema, and accepts writes, and table_name, when given, is a usable name nobody else holds. It answers with the catalog and schema the table would go in and, with a table_name, its query_table; otherwise with the refusal register would give. Every refusal of a connection names the connections you can register a table on. A script's platform.export(register=...) asks it before writing its file

A write over the file — manage_resource replace_content, a manage_asset content edit, a script's platform.export — moves every following table onto the version it wrote and reports each table in its result (table_changes, one sentence per table), so the write that leaves a pinned table behind says so. See Following the file.

Registering is the authority to change the file, not the authority to read it: an asset by its owner or an administrator, a resource by its uploader or an administrator of its scope. A reference naming a file you may not register is answered as absent, whether it is missing, deleted, or somebody else's. See Registered Tables.


manage_resource

Manage a file in the managed resource library. A managed resource is the only kind of file a saved asset can reference, and until this tool existed the only way to put one there was a person at an upload form. That left the data half of a referencing asset unrefreshable by the platform itself: an agent could rewrite a report on a schedule and could not rewrite the CSV the report reads.

The loop it closes is one call each way. create files the data and reports the mcp:// URI to hand to save_asset's references argument; replace_content writes new bytes over that file later. Because a replacement keeps the resource's id, its canonical URI and its filename, every asset referencing it serves the new content without being re-saved, and every citation and prompt attachment pointing at it keeps resolving.

The other three actions close the lifecycle around those two (#1665). get answers what is filed at an address, list reports a folder, and delete removes a file. Without them a caller had to remember the id of everything it ever wrote: create refuses an address that is already taken, replace_content needs an mcp:resource: reference, and search is relevance-ranked, so a file it does not return is not a file that is not there.

Parameters:

Parameter Type Required Default Description
action string Yes - create, replace_content, update, get, list, delete or extract
reference string Conditional - The resource to act on, mcp:resource:<id>. Required for replace_content, and for extract, where it names the archive; for get and delete it is the alternative to naming the address
if_exists string No fail What a create or an extract does when an address already holds a file: fail refuses it, replace records the next version of what is there
members string No every file For extract: a glob choosing members. *.csv matches a member's file name wherever it is filed; a pattern with a slash, such as exports/*.csv, matches the member's whole path
force boolean No false Delete a file even though something still points at it
limit, offset integer No 100, 0 Page a list; 100 is also the largest page
content string Conditional - The file as text: CSV, JSON, Markdown, SVG
content_base64 string Conditional - The file as base64-encoded bytes, for a binary file such as a PNG or a PDF. One of the two, never both
content_type string Conditional - Media type the bytes are (required for create); replace_content keeps the type the resource already carries when it is absent
filename string Conditional - Name of the file. Required for create, and for a get or delete that names the address; replace_content ignores it. For extract it is optional and files the one selected member under this name directly in path
display_name, description string Conditional - Required for create: what the library shows
path string Conditional - The folder path inside the library, for example datasets or datasets/media-manager/shows. Required for create, and for a get or delete that names the address; for list it is the folder the listing is rooted at, and everything beneath it is included. Each folder name is lowercase letters, digits and hyphens starting with a letter or digit; at most 8 folders deep and 200 characters. Two files with the same filename in the same folder collide; in two folders they do not
tags string[] No [] Tags for filtering in the library
scope, scope_id string No your own user scope user, persona, or global
change_summary string No generated Why the content changed, shown in the version history beside the revision
index string No the deployment's rules How search indexes the file: full (its details and a prefix of its contents), metadata (its name, description, folder, filename and tags, nothing read), or none (not in search one by one; its folder is found instead). Taken by create, replace_content, update and extract; a rule in resources.index may cap it (Search indexing)

Actions:

  • create: file new content and report its resource_id, its mcp:resource: reference, and its mcp:// URI
  • replace_content: write new bytes over an existing resource and report the version the content was recorded as, and table_changes: one sentence per table registered over the file, saying it followed onto the new version or is pinned and now behind it (Following the file)
  • get: report what is filed at an address (scope + path + filename), or what a reference names, without the bytes. found: false and the uri it looked up is the answer for an empty address, not an error
  • list: report the files under a folder, newest first, with the total the page was cut from
  • update: change a file's display_name, description, tags or index without touching its bytes. With path and description and no reference, it writes the description of the folder at path instead, which is what search matches a folder of unindexed files on
  • delete: remove a file and its version history
  • extract: write the files inside a stored zip, gzip or gzipped tar out as managed resources under path, and report each one's member name, reference, uri, version, size_bytes and table_changes. See Extracting an archive below

Landing one rolling file. A caller that lands the same file on a schedule passes if_exists: replace on create. The first call creates the file, every call after it records the next version of that same file, and the result's version says which happened. The address is the identity, so the caller keeps no id: a managed script whose state is cleared or rewritten lands its next run at the same address instead of colliding. It is the same create-or-replace an export's resource destination performs, reached from the write tool rather than from an export.

Extracting an archive. Many upstreams deliver data as an archive, a monthly .zip holding one large CSV, and a query engine cannot read inside one. extract names a stored archive by its reference and writes its members out as managed resources, streaming: the archive is read from storage by range and each member goes to storage without being held, so a member of several hundred megabytes extracts within a script run's memory budget. It is bounded by its own limits, resources.managed.extract, rather than by max_upload_bytes; see Archive extraction limits.

  • Where members land. Under path, keeping the member's own folders beneath it. A folder name the library cannot hold is converted the way a folder upload converts it: lowercased and other characters turned into hyphens, so Reports/Q3 2026/feed.csv lands at <path>/reports/q3-2026/feed.csv and reports/2026/feed.csv at <path>/reports/2026/feed.csv. With filename, the one selected member is filed under that name directly in path whatever the archive called it, which gives a delivery whose member is renamed every month (export_2026_10.csv) one stable address.
  • Rolling files. With a fixed filename and if_exists: replace, each extraction records the next version of the same file, so a table registered over it with follow left on moves to the new month's data with no re-registration. It is the same contract a resource destination on an export has.
  • What is refused, before anything is written. A member name that is absolute, carries a drive letter, or climbs out of its folder with .., anywhere in the archive and whether or not it is selected; an encrypted member; a zip member compressed by anything but stored or deflate; a selection matching nothing (the refusal lists what the archive holds); a filename with more than one member selected; two members that would land at one address; an address already holding a file without if_exists: replace; and an archive past any extraction limit, named by its key. Links and devices inside an archive are skipped and listed as skipped.
  • A failure part way through. A member whose bytes turn out damaged, a checksum that does not match, or a gzip passing a limit as it streams stops the extraction. The member being written is abandoned, so nothing partial is stored, and the error names every member written before it with its reference and version.
  • Formats. A zip (stored and deflate members, zip64 included), a gzip of one file, named after the archive without its .gz, and a gzipped tar (.tar.gz, .tgz), told apart from a plain gzip by the tar header inside it rather than by the file's name.

Extracting is a write, so a script draft reaches it only with allow_writes, and each member is written under the same authority a create at that address needs. Reading the archive needs the authority to see it.

What a delete refuses. Two kinds of record point at a managed resource and neither is a foreign key: an asset's content references it, and a prompt attaches it as reference material. Deleting the file deliberately leaves both rows behind, so the thing that depended on it reports the material as missing rather than losing the evidence it ever had any. Nothing in the database therefore stops the delete, which is why the tool does: a delete is refused while either of them, or a query-engine table registered over the file, still points at it, and the refusal says how many of each. Pass force: true to delete anyway, which leaves each of those pointing at a file that is not there and drops any table over it.

A knowledge page is not a third: the platform refuses an mcp:resource: citation on a shared page, because a resource is visibility-scoped and the citation would be broken for every reader outside that scope, so no page can point at one.

The counts are counts and not names because each of those records carries an audience of its own and the person deleting the file is not necessarily in any of it; the portal's Used-by panel on the file resolves those audiences and names what its reader may open. The tables are reported as table_registrations, the rows manage_table action=list answers with, because manage_table already names them to the same caller. A deployment that cannot establish what depends on a file refuses the delete and says so rather than reporting that nothing does.

Declaring the type. A create says what the bytes are and a create that does not is refused. The type is not detected on this path because the families an agent writes cannot be named from content: SVG, HTML, JSX and Markdown are all stored text/plain when nothing is declared, and text/plain is served under nosniff, so an <img> naming that file is a broken image on every surface with nothing reporting a problem. A replace_content keeps the type the resource already carries, so refreshing a file cannot reclassify it under every reference to it; declare one there only to change what family the file is. The types to choose between are listed on the built-in knowledge page mcp:knowledge_page:platform-content-types-for-stored-files and in Content Types and Viewers.

Both are capped at portal.max_content_size (10 MB by default), the same cap save_asset applies to an agent's content: one number governs everything an agent writes through a tool call. A file larger than that is uploaded through the portal's resource library instead.

A replacement goes through the same revision path the portal's replace-content button uses, so it lands in the resource's version history with its author and its change summary recorded, the prior version stays restorable, and the resource is re-announced to connected clients so nobody keeps serving the bytes it moved off.

Who may write. Creating is scope authority: your own user scope (the default, and the one place every signed-in caller may write), a persona you administer, or the global scope as a platform administrator. A refusal names the scope rather than the file, because where it was filed is what the caller has to change. Replacing and deleting are the authority to change that file: its uploader, or an administrator of its scope. Deleting is not a stronger authority than overwriting, because a replacement already leaves nothing of the previous content beyond the version trail a delete takes with it. A resource you cannot see is answered as absent, whether it is missing, deleted, or somebody else's. A list shows only the libraries you can see, and naming one you cannot narrows the answer to nothing rather than widening it.

A managed script reaches this tool through platform.call like any other, under its version author's permissions, which is what makes a scheduled refresh of a referenced file possible without a person in the loop. A run authenticates as a principal that owns no file, so the resource rules read the address it acts for: a create with no scope named files into the author's own library rather than the principal's, and a replacement reaches a file the author uploaded through the portal. See Script security and Asset References.


Sharing an asset from the session

"Share this with John" is one call, not a trip to the portal. manage_asset action=share takes the same three decisions the portal's share dialog takes — who, what they may do, how it ends — and mints the same share row, through the same constructor the REST routes use, so a share created here is indistinguishable from one created in the UI.

Naming a person. recipient accepts an email address, or a person's name. A name is resolved against the known-users directory — the same directory the portal's share picker reads — matching case-insensitively against email, first name, and last name; a full name ("John Smith") is resolved by narrowing to the entries every word appears in. The lookup refuses to guess: a name matching nobody, or more than one person, comes back as an error naming the candidates, and no share is created. An email address is taken as written, so a person who has never signed in can still be shared with.

A person share is restricted: it opens only for the named recipient (and its creator), whoever else holds the URL. It is viewer unless permission: editor is asked for, it never expires — it ends when it is revoked — and the recipient gets the same "shared with you" email the portal sends, subject to their own notification preferences. The response reports notified, so an agent only says someone was emailed when one was actually sent.

Minting a link. Omit recipient and the share is a link. access_mode: authenticated (the default) opens for any signed-in platform user and lasts until revoked. access_mode: public opens for anyone holding the URL without signing in; because holding the URL is then the whole of the access check, expires_in is required for it and refused for everything else (#1279). A link is always viewer: it admits an audience the creator never enumerated, so it can never carry write access.

Seeing and ending access. list_shares returns the shares on an asset that currently grant access — a revoked or expired share is not one of them — each with its recipient, permission, access mode, view URL, and access count. revoke_share ends one by share_id; its token stops opening the asset immediately.

All three are owner authority. An editor share on an asset never carries the right to hand that access on, an unauthenticated caller is refused outright, and an admin is unrestricted as everywhere else.


Editing content in place

Regenerating a whole document to change one sentence costs output tokens proportional to the size of the document rather than the size of the change, and every regeneration is a chance to silently drop an unrelated paragraph. manage_asset and manage_prompt therefore share one content-editing grammar, implemented once in pkg/textpatch: the same argument names, the same operations, and the same error codes on both tools.

The intended loop for a large document is outline (or locate) to decide where, then patch that place. The body crosses the wire in neither direction.

The verbs

Verb Purpose
outline Heading tree with levels, line numbers, and per-section byte size; on an HTML/JSX/SVG asset also the addressable landmarks (elements with an id or data-* marker)
get_content Read a span: the whole body, one section or selector-addressed element, or a line_range
stats Size, line count, current version, content type, body hash
locate Find literal or regex matches: count, line numbers, enclosing section, context windows
patch Apply an ordered list of anchored edits
diff Compare two versions, or a pending prompt draft against the approved snapshot

Patch grammar

A patch is an ordered list of edits applied to the current body in memory. Nothing is written until every edit resolves.

{
  "action": "patch",
  "asset_id": "ast_...",
  "base_version": 7,
  "edits": [
    { "find": "revenue grew 12% year over year", "replace": "revenue grew 14% year over year" },
    { "op": "replace_section", "section": "## Methodology", "text": "## Methodology\n\nRestated..." },
    { "op": "insert_after", "find": "## Findings", "text": "\n\nAll figures are quarter-end.\n" },
    { "op": "replace", "pattern": "Q[1-4] FY24", "replace": "$0 (restated)", "occurrence": "all" },
    { "op": "move_section", "section": "## Appendix A", "after": "## Appendix B" },
    { "op": "append", "text": "\n\n## Appendix C\n\n..." }
  ],
  "change_summary": "correct the YoY figure, restate methodology, reorder appendices"
}

Operations:

  • replace (the default when op is omitted): find is matched literally and swapped for replace. An explicitly empty replace deletes the matched text.
  • insert_before / insert_after: text is placed relative to the find anchor, leaving the anchor in place.
  • replace_section: names a region with section or selector (see below) and replaces its whole span with text.
  • replace_content: names an element with selector and replaces its interior with text, leaving the element's own tags exactly as written — the data-island operation. Selector-only; a void or self-closing element is refused because it has no interior.
  • move_section: relocate a whole region before or after another heading, or with position set to start or end.
  • append / prepend: text at the end or start of the body. No anchor needed.

section and selector are also accepted on replace, insert_before, and insert_after to scope the anchor search to one region, which is how a repeated phrase becomes unambiguous without quoting a long anchor.

The payload key is required, and it is the only one

An edit carries the text it writes under replace for op: replace, and under text for every other writing operation. An edit that omits the key its operation reads is REFUSED with PATCH_BAD_EDIT naming the edit's index; so is one carrying a key this grammar does not declare, which is named along with the accepted set.

Deleting is therefore always something the caller asked for: "replace": "" written out. Omission used to be read as an empty replacement, so an edit whose text rode on a misspelled key — text on a replace, replacement, with — had its payload ignored, replaced the anchor with nothing, saved a new version and reported success. The damage was a deletion the caller never asked for, in a source that usually still parsed, so validate passed too and a scheduled run was the first thing to notice.

Every tool that adopts this grammar splices the same schema fragment, so its edit items declare exactly these keys and are closed to the rest: an unknown key is refused by the argument validator before the handler runs.

Naming a region

How a region is named is derived from the asset's content type, through the platform's single media-type seam (pkg/contenttype); it is never guessed from the bytes.

  • Markdown (text/markdown, text/plain): section names an ATX heading (## Methodology, or a Report > Methodology path when headings repeat). The span runs from that heading to the next heading of the same or higher level.
  • HTML, JSX, SVG, XML (text/html, text/jsx, image/svg+xml, application/xml): section names an <h1>..<h6> heading, resolved exactly as markdown resolves #. selector names an element by CSS selector, and the region is that element's balanced subtree, running from its start tag through its matching end tag, so a replace_section or move_section can never cut a tag in half. selector is refused on a markdown or structureless document, with a message naming the right alternative.
  • Everything else textual (JSON, CSV, SQL, ...): has no addressable structure. section and selector are refused with PATCH_NO_STRUCTURE; use anchored edits.

The supported selector forms are type (section, Card), #id, .class (which also matches a JSX className), [attr] and [attr=value], joined by descendant (space) or child (>) combinators. Element type selectors match HTML and SVG tags case-insensitively; a JSX component name (Card) is matched case-sensitively. A selector that matches several elements is refused with PATCH_AMBIGUOUS naming the count, and occurrence (first, last, or a 1-based index, since a region is a single element and all does not apply) is the explicit opt-in, exactly as for a repeated text anchor.

{
  "action": "patch",
  "asset_id": "ast_...",
  "edits": [
    { "op": "replace_section", "selector": "[data-region=\"revenue\"]", "text": "<Card data-region=\"revenue\">...</Card>" },
    { "op": "replace", "selector": ".metric", "occurrence": 2, "find": "Users", "replace": "Active Users" }
  ]
}

On a headingless dashboard, outline returns the addressable landmarks: every element carrying an id or a data-* marker, each with its tag, a copyable selector, line, and byte size, so an agent can find where to patch without reading the body.

Matching rules

An anchor must resolve to exactly one span. Zero matches and more than one match are both errors, and the error reports the count. occurrence (first, last, all, or a 1-based integer) is the explicit opt-in when the caller means a specific one or all of them; an occurrence: "all" edit reports how many spans it changed.

Matching is exact first. If an exact match fails, one retry normalizes CRLF to LF and ignores trailing whitespace on each line; if that resolves uniquely the edit applies and the response marks it normalized: true. Nothing beyond that: no fuzzy, similarity, or semantic matching, because a plausible-but-wrong edit applied silently is worse than a rejection the agent can correct.

pattern is the regex alternative to find, on both locate and patch, with $1-style capture references available in replace. Go's regexp is RE2, so match time is linear in input length and a pathological pattern cannot hang the server; a pattern-length cap, a match cap, and the same all-or-nothing failure rule still apply.

Edits apply in order against the evolving body, so a later edit can anchor on text an earlier edit introduced.

Atomicity and staleness

All edits resolve against an in-memory copy; the first failure aborts the entire call and writes nothing. The error names the failing edit by index. base_version is optional and checked when supplied; a mismatch is refused with the current version in the error so the agent re-reads and retries. Reads return the version, so a well-behaved agent threads it and gets lost-update protection for free.

Response

The response never echoes the new body. It returns the new version number, the new size, and a per-edit outcome (the operation, whether it normalized, how many spans it touched, the line where it landed) plus a unified diff of the changed hunks only.

Unified diff is rejected as an input format and adopted as the output format. As input it would make correctness depend on line numbers and context lines the model must reproduce exactly; as output it is generated by the server from two known strings, and it is the most compact accurate description of what changed.

dry_run: true resolves every edit and returns exactly that report, diff included, without writing.

Search and navigation

locate takes find (literal) or pattern (regex) and returns, per match: the line number, byte offset, enclosing section heading, and a context window wide enough to copy verbatim into a find anchor, plus the total match count. The count is the point: an agent that checks first never hits PATCH_AMBIGUOUS.

Line numbers are read output only and are never accepted as an edit anchor. A line number is stale the moment a preceding edit lands, whereas an anchor either matches or errors. line_start / line_end exist on get_content for reading a span and nowhere else.

Text only

Every verb here is text-only. A PDF, image, parquet, or other binary asset is refused with PATCH_NOT_TEXT rather than corrupted or dumped as garbage, using the platform's single media-type detection seam (pkg/contenttype).

Errors

Corrective, self-describing envelopes carrying {code, category, message, hint}:

Code Meaning
PATCH_NO_MATCH Anchor text not found, with the edit index. Run locate and copy the anchor verbatim.
PATCH_AMBIGUOUS Several matches for an anchor or a selector with no occurrence. Lengthen the anchor, scope it with section or selector, or set occurrence.
PATCH_STALE_BASE base_version does not match the current version. Re-read and retry.
PATCH_NOT_TEXT The target's content type is not textual.
PATCH_TOO_LARGE Too many edits, too many regex matches, or a result exceeding the deployment's max content size.
PATCH_SECTION_NOT_FOUND Named heading absent (with the document's headings in the message), or a selector matched no element.
PATCH_BAD_PATTERN Regex fails to compile or exceeds the pattern cap.
PATCH_BAD_EDIT The edit names no anchor, names both find and pattern, names both section and selector, or uses an unknown operation.
PATCH_BAD_SELECTOR The CSS selector does not parse, with the parse error.
PATCH_NO_STRUCTURE The content type has no sections or elements to address; use anchored edits.
PATCH_UNRESOLVED_MARKUP The markup could not be resolved into a reliable element tree, so no element span was trusted; nothing was written.

manage_feedback

Review, open and respond to feedback. Feedback is its own tool (rather than actions on manage_asset) so an agent discovers it by name. Threads live on an asset, collection, prompt or knowledge page, or on the shared general channel.

Parameters:

Parameter Type Required Default Description
action string Yes - list, create, get, reply, resolve, request_validation, respond_validation, edit, delete
asset_id / collection_id / prompt_id string No - Scope a list to one target, or the target create opens a thread on
knowledge_page_id string No - The knowledge page create opens a thread on
target_type string No - standalone scopes a list to, or opens a create on, the general channel
thread_id string Conditional - Required for get, reply, resolve, request_validation, respond_validation, edit, delete
event_id string Conditional - Required for edit and delete: the message, an event id from get
body string Conditional - Comment text (required for reply, for create, where it is the first comment, and for edit, where it replaces the message's text)
kind string No comment For create: comment, question, correction, rating, approval, rejection, suggestion
visibility string No internal For create: internal (the organization's own people) or shared (everyone who can open the target, external people included)
anchor object or string No - For create: where in the target the thread points, a JSON object or a string holding one
target_version integer No - For create: the target version the thread was written against
status / validation_state / requires_resolution - No - Filters for a targeted list; requires_resolution also marks a new thread from create
validation_result string Conditional - validated or disputed (required for respond_validation)
validation_reason string No - Optional reason recorded on the validation event
limit / offset integer No 50 Pagination

Actions:

  • list (no target): The entry point for "review and act on any pending feedback." Returns the caller's pending feedback across the assets and collections they own or can edit AND the shared general channel — unresolved threads they did not author — plus any threads awaiting their validation. Newest first. (Prompt-thread feedback is reached by targeting the prompt with prompt_id, admin-only; it is not part of the no-target feed.)
  • list (with a target): Threads on one asset/collection/prompt or the standalone channel, filterable by status / validation_state / requires_resolution.
  • create: Open a new thread, with body as its first comment, on exactly one of asset_id, collection_id, prompt_id, knowledge_page_id, or target_type=standalone (#2371). It is opened through the portal's own route, POST /api/v1/portal/threads, served inside the process as the caller, so the portal's terms apply unchanged: anyone who can view the target may comment on it, the standalone channel and knowledge pages are open to every signed-in person, a persona whose portal Feedback area is off is refused, the caller is the author, a thread a person of an external persona opens is shared, and the mentions and notifications are the portal's. A refusal is the portal's own words and status. It needs the HTTP server, which serves the portal; under stdio it answers that the HTTP API is not served. A thread takes no title (#2513): it is listed by the first line of its first message, its author and its target, and a call that still sends title is refused as an unknown argument.
  • list rows carry first_message, the first line of the thread's first message (a deleted message is skipped); a thread opened before #2513 also keeps the title it was stored with.
  • get: One thread plus its full event timeline.
  • reply: Append a comment to a thread. Without a thread_id the error names action=create.
  • resolve: Mark a thread resolved.
  • request_validation: Route a validation request to the thread author.
  • respond_validation: The thread author (or an admin) records validated/disputed; disputing re-opens the thread.
  • edit: Replace the text of a message the caller wrote (#2514): thread_id, event_id, body. Only a comment can be edited (the opening message of a comment, question, correction or suggestion thread, and every reply); an approval, rejection, rating, status change, resolution, validation request or result, linked insight or changeset, or visibility change is refused, so an edit never moves a sign-off. Only the author edits. The previous text is kept in the database, the message reads as edited with the time, and a person the new text mentions who was not mentioned before is notified; nobody else is told, and saving the same text again tells nobody.
  • delete: Delete a message (#2514): thread_id, event_id. The author, the target's owner or editor, and an administrator may. The same comments-only rule applies. The thread keeps the message's place, shown as deleted, and no route returns its text or the people it mentioned: the thread's events, the thread list, the activity feed, search, the mentions inbox and the notification queue (rows not yet sent are dropped, the rest lose the snippet). Deleting a thread's opening message when nothing else was said or decided deletes the thread. The text stays in the database for the audit and goes with the thread when it is purged.

Edit and delete are served by the portal's own routes, PATCH and DELETE /api/v1/portal/threads/{id}/events/{eventID}, inside the process as the caller, so the rules are the portal's on both surfaces. A change made in the portal is audited as a manage_feedback call with source portal, naming the thread and the message and never the text; one made through the tool is audited as that tool call.

Access: create is the portal's rule, above. Everything else is scoped to the assets and collections the caller owns or can edit (admins see all), and a thread's author also gets and replies to it when the portal lets them read it, which is how a viewer follows a thread they opened. General-channel threads are readable and replyable by any authenticated caller, and resolved only by the thread author or an admin. memory_capture thread_ids=[...] folds a thread into the knowledge loop and resolves it, gated by the same owns-or-edit check.


Inspecting and Managing Tools (Admin Portal)

The portal at /admin/tools is a master-detail view: a left rail listing every registered tool grouped by connection or kind, and a right pane with five tabs for the selected tool.

Admin Tools: the Overview tabAdmin Tools: the Overview tab

Detail tabs

Tab Purpose
Overview Description (editable, see overrides below), routing (toolkit / kind / connection), persona allow/deny matrix with matched pattern, raw input schema.
Try It Dynamic form generated from the tool's input schema. Submits a real tools/call and renders the result with optional enrichment blocks. Per-session history with replay.
Activity 24-hour aggregate from the audit log: call count, success rate, average duration. Links to /admin/audit?tool=<name>.
Enrichment Gateway-proxied tools only. Lists cross-enrichment rules attached to this tool, with merge strategy and enabled state. Links to the connection's enrichment drawer.
Visibility Toggle the global kill-switch (see tools.deny below) and preview a persona's decision for this tool without editing persona rules.

Try It submits a real tools/call against the running platform, so the result below the form is the result an agent would get:

Admin Tools: the Try It tabAdmin Tools: the Try It tab

Activity reads the same audit rows the audit log holds, narrowed to this one tool:

Admin Tools: the Activity tabAdmin Tools: the Activity tab

Enrichment appears only for a gateway-proxied tool, and lists the rules attached to it:

Admin Tools: the Enrichment tabAdmin Tools: the Enrichment tab

Description overrides

A tool's description is what an LLM agent sees in tools/list. Overriding it is the most reliable way to steer agent behavior — for example, to insist that trino_query calls search first to discover the table.

Overrides are persisted as config entries with the key tool.<name>.description. Resolution order, last wins:

  1. Built-in defaults in pkg/middleware/mcp_descriptions.go — currently trino_query and trino_execute redirect agents through search first.
  2. File-config overrides in tools.description_overrides of platform.yaml.
  3. Database overrides authored from the portal Tools page, stored in the config_entries table.

The Overview tab shows an overridden badge with the author when a database override is in effect. The "Reset" button removes the database override; the file-config or built-in default takes over. Overrides are picked up at platform startup — saving from the portal updates the live config struct immediately, but the tools/list response continues to serve the previously-cached description until restart.

Global kill-switch (tools.deny)

tools.deny is a glob list that hides matching tools from tools/list responses for all clients. It is a cosmetic / token-budget filter, not a security boundary — persona authorization continues to gate tools/call independently.

Admin Tools: the Visibility tabAdmin Tools: the Visibility tab

Three equivalent ways to set it:

  • Edit tools.deny in platform.yaml (file mode).
  • PUT /api/v1/admin/config/entries/tools.deny with a JSON-encoded string array as value.
  • Click "Hide tool" on the Visibility tab. The portal does a read-modify-write of the tools.deny config entry, appending the literal tool name.

When a deny pattern is a glob (e.g. *_admin_*) rather than a literal name, the Visibility tab will surface a warning that toggling here only changes the literal entry — the glob must be edited via Config.

Admin API surface

Endpoint Use
GET /api/v1/admin/tools Inventory of every registered tool with kind / connection.
GET /api/v1/admin/tools/schemas Bulk fetch input schemas and the annotations each tool advertises.
GET /api/v1/admin/tools/{name} Aggregating per-tool detail used by the master-detail page, including its annotations.
POST /api/v1/admin/tools/call Invoke a tool with parameters; returns the same content envelope clients see.
PUT /api/v1/admin/tools/{name}/visibility Add/remove the tool from tools.deny (read-modify-write under the hood).
POST /api/v1/admin/personas/{name}/test-access Preview a persona's allow/deny decision for one tool.
PUT /api/v1/admin/config/entries/tool.<name>.description Save a per-tool description override. Only accepted for keys whose <name> matches a registered tool.
DELETE /api/v1/admin/config/entries/tool.<name>.description Remove an override and revert to the file or built-in default.

See Admin API for full request/response shapes.


Platform Tools

platform_find_tools

platform_find_tools(query, limit) ranks the platform's own registered tools by semantic similarity to a natural-language task description, so an agent can discover the right tools by intent instead of scanning every tool name. It is the tool-catalog analogue of api_discover's semantic ranking.

  • Indexing — every globally-visible tool's descriptor (name, description, and a parameter-schema summary) is embedded through the shared index-jobs framework (source_kind = "tools") and persisted to the tool_embeddings table. On each reconcile sweep the tools gap check diffs the live registry against the persisted vectors by descriptor text hash, so a tool addition, removal, description-override edit, or visibility flip is picked up within one interval, while a steady-state corpus produces no job and the index settles rather than re-running every sweep. When a job does run, the worker's text-hash dedup re-embeds only the descriptors that actually changed. Embeddings are persona-neutral (indexed once for the whole catalog).
  • Ranking — the query is embedded and ranked against the stored vectors with pgvector cosine distance. When no embedding provider is configured or the index is empty, it falls back to a lexical name/description match and sets a note explaining why (the same UX as api_discover).
  • Persona scoping — results are filtered at read time to the tools the caller's persona is permitted to call, exactly like tools/list. The model never sees a tool it cannot call. (Row-level filtering, not per-persona embeddings.)
  • Response — { "tools": [ { "name", "description", "score" } ], "note"? }, ranked most-relevant first and capped at limit (default 10, max 50).

This is discovery, not routing: the agent still chooses which returned tool to call.

manage_prompt

manage_prompt manages database-stored prompts (create, update, delete, list, get) and resolves any prompt to run with the use command. list supports a ranked free-text query over the caller's visible prompts, the same rule browsing applies: approved shared prompts plus every prompt the caller owns at any status, and everything for admins (hybrid semantic + lexical when an embedding provider is configured, lexical otherwise).

Editing without resending. manage_prompt carries the same content verbs as manage_asset (patch, locate, get_content, outline, stats, diff) with the identical grammar documented in Editing content in place. Renaming a step in a long operating procedure is one patch call whose arguments are the size of the change. A patch routes through the same review gate as any other content edit: patching an approved global or persona prompt produces a pending draft version and the approved snapshot keeps being served until an admin approves it. diff with no versions named compares the newest pending draft against the version currently being served, which is the question a reviewer actually has.

use: resolve and run. When a user names a report, procedure, or recurring task ("run the daily sales report"), the agent resolves it against the prompt library instead of enumerating prompts. use accepts any handle in name:

  • an exact bare name (daily-sales-report),
  • a display name, case-insensitively ("Daily Sales Report"),
  • an mcp:prompt:<id> reference (the same reference search results carry),
  • or free text, ranked against the library.

A single confident match returns status: "resolved" with the prompt content (argument values passed in args are substituted), its argument specs, any required arguments still missing, and provenance (scope, status, version, approver, owner, reference) so the agent can confirm what it is about to run: "running Daily Sales Report v4, approved by jane@example.com". An ambiguous handle returns status: "ambiguous" with a short ranked candidate list to disambiguate, never an error and never a silent first-match. Operator, workflow, and toolkit prompts resolve through use as well (they remain read-only to the management commands); the auto-generated platform-overview prompt is served only on the native prompts surface.

Non-admins manage only their own personal prompts; admins manage every scope. An admin creating a global or persona prompt is its approver: the prompt lands approved (and therefore searchable) with the approval stamped, rather than sitting in draft. create and update also accept collection_id to place the prompt in a shared collection (an id from the collections array list returns; an empty string clears the placement; collections themselves are created in the portal). On the native MCP prompts surface, database prompts are presented under per-viewer scope-prefixed names (see Configuration: Prompts); use insulates users from those presented names entirely.

Versioning and review. Every mutation of a prompt's content, display name, description, arguments, or tags snapshots an immutable version with its author; approval stamps bind to the specific version approved. Editing the content or arguments of an approved global or persona prompt does not apply immediately: update returns status: "pending_approval" with the draft's pending_version, and every caller keeps being served the approved snapshot until an admin approves the draft (in the admin portal, or POST /api/v1/admin/prompts/{id}/versions/{version}/approve). A gated content edit cannot be combined with scope/status/other non-versioned changes in one call; submit them separately. Personal prompts and never-approved drafts version silently. get and list also report run_count and last_run_at per prompt, aggregated from prompt-serve audit events (each prompts/get and resolved use counts as a serve), and list results include the shared collections list (the portal's organization model) when the store supports collections.

Attached materials. A prompt can carry the reference material its procedure depends on: the report template it fills, the checklist it follows, the brand header it embeds, the sample payload it matches. Attachments are links to managed resources, stored by resource id, so editing the uploaded file updates every prompt that attaches it.

A resolved prompt delivers them after the prompt text. Text material at or below 64 KiB arrives inline as an MCP embedded resource; anything binary or larger arrives as a resource link the client reads on demand. use also lists them in an attachments array carrying each item's URI, media type, size, and availability, so the agent can state what materials it received. The delivered material is framed as authoritative: an attached template is to be filled rather than reinvented, an attached checklist followed.

An attachment must be at least as widely visible as the prompt that carries it, so a shared SOP never arrives with materials most of its audience cannot read:

resource scope may be attached to
global any prompt
persona P personal prompts, and persona prompts scoped to exactly P
user the author's own personal prompts only

The rule is enforced when the attachment is made and again when the prompt changes scope, so requesting promotion of a personal prompt that carries a private template is refused with a message naming the resource. It is checked a third time at serve time against the caller: a reader who cannot read an attachment receives the prompt with a note that some materials were not delivered, never their contents and never their names. Deleting an attached resource does not break the prompt; it still serves, and both the served result and the portal flag the material as missing.

Authors manage attachments from the prompt viewer in the portal, and the resource detail view lists the prompts that attach a resource, so the cost of deleting it is visible first.

Referenced scripts. A prompt can also reference the managed scripts its procedure depends on: the report the analysis reads, the export it compares against. attach_script adds one and detach_script removes it; both take script, either the mcp:script:<id> reference search and fetch return or a bare script id from manage_script. Referencing is an edit to the prompt and takes the same authority every other prompt mutation does.

Serving a prompt delivers each referenced script's contract — what it is, what parameters it takes, whether a run would be admitted, its schedule — plus the instruction to call run_script for fresh output. use also lists them in a scripts array. Serving never executes a script: a prompt read is a read path, and running code from it would blur audit attribution and turn every read into a potential asset write. The contract does not carry the script's source; fetch on the reference and manage_script get_content do.

A script's definition is readable by everyone signed in (#2027), so a reference resolves for every signed-in reader of the prompt, and any signed-in caller may reference any script. The last successful run, with what it produced, is included for the script's owner and administrators; anyone else's contract carries runs_withheld. Deleting a referenced script does not break the prompt; it still serves, and the payload reports the reference as no longer existing.

Prompt browser app. In MCP Apps-capable hosts, the built-in prompt-browser app is bound to the show_prompts tool. show_prompts is presentation-only: its only job is to render the interactive library browser (search, facets, argument forms, a Run action) for the human, so an agent calls it only when the user wants to see their prompts. manage_prompt carries no app and renders nothing, so the agent's own prompt work (resolve, run, create, edit) never puts a UI in front of the user. The rendered app populates itself from its own manage_prompt calls. See MCP Apps: Overview. manage_prompt's JSON results are complete on their own in clients that do not render apps.

manage_script

manage_script authors, validates, and dry-runs managed scripts: small Starlark programs the platform stores, versions, and governs, so a process whose logic is already solved (a KPI report, a recurring export) can be re-run without deriving it again through a conversation. Write a script when the logic is settled and the work will repeat; keep using the query tools directly while you are still exploring.

The loop. create (or update/patch), then validate, then run_draft. validate parses and resolves the source without executing it, and reports the capabilities and connections the code references, plus a finding for any destination this deployment does not declare. run_draft executes it for real, under your own identity and persona, with tighter limits than a saved run has, persisting nothing: platform.export reports the shape of each output instead of writing it, and a platform.call that would persist is refused and named in refused_write, so a landing pipeline is rehearsed without landing. Send allow_writes=true when a later step needs what an earlier one creates; the run then writes for real and lists every write it made under writes. Both act on the source argument when one is sent and on the saved version when it is not, so an edit can be checked and executed before it becomes the version that runs. Commands: create, update, patch, delete, get, list, diff, versions, validate, run_draft, help, the run-history commands runs and get_run, the state command state (state_action get, set or clear, the owner's and an administrator's read and reset of the one object a script carries between runs), the schedule commands schedule_set, schedule_list, schedule_enable, and schedule_disable, plus the shared content verbs (locate, get_content, outline, stats) documented in Editing content in place.

Sending less. create and update take source_ref, a managed resource holding the source (mcp:resource:<id> or its mcp:// URI), in place of source (#2114). A create the gates refuse is kept as a pending create of its name, which patch name=<name> fixes and saves once it passes and get reads meanwhile. A refusal leaves the formatted source out, and a diff shows a line longer than 400 characters as the span that changed, unless verbose=true (#2115). A draft is held to 60 seconds; an administrator's run_draft may ask for timeout_seconds up to scripts.draft.max_seconds, and one longer than 60 seconds runs in the background, answering with its run_id, which get_run reads (#2117).

Who owns it and who wrote it. owner_email, on get and on each row of list, is the person the script is filed under now. An administrator can move a script to another owner, and that field moves with it. The author does not: every version records who saved it and the roles they held at that save, and versions reads that history newest first — version, author, author_roles, status, created_at, and the descriptive fields the snapshot carried (display_name, description, category, tags). The oldest entry names whoever created the script, so the question survives a transfer, and the roles on an entry are the authority a run of that version presents. It carries no source; read an earlier version's code with diff. Its visibility is get's, and a deployment whose store keeps no versions refuses it rather than answering with an empty history.

Reading another person's script. A script is how a resource or an asset was produced, so its definition is readable by everyone (#1866): name its owner with owner_email and get, get_content, outline, stats, locate, diff and versions answer as they do for your own. Two things stay with the owner and administrators even there: get leaves out live_runs, and versions leaves out author_roles. Running it, scheduling it, changing it, and reading its runs and its state are the owner's and an administrator's, and any of those naming another person's script is refused in those words.

The dialect, in-context. Call help before writing your first script. Starlark is Python-shaped but deliberately smaller, and help states exactly what is available and what a Python instinct will reach for and not find, with the corrective form for each. help also returns a see_also list naming the platform's built-in authoring pages by slug, each with the reference fetch reads it by, so the reasoning the contract states in outline (output identity, the choice between composing a whole document per run and refreshing one document's data region, provenance) is one call away rather than dependent on a search ranking it. get also retrieves seeded worked examples (example-daily-sales, example-region-rollup) so a first script starts from a script that runs. validate recognizes the predictable Python-isms and answers with a correction rather than a bare parse error: import is not available (json and date are already predeclared), try/except does not exist (an error fails the run by design; stop deliberately with fail("why")), f-strings are not supported (use .format() or %), while and recursion are off, and there is no clock, no randomness, no filesystem, and no network.

What a script can reach. A closed, enumerable set, because an enumerable surface is what makes a review meaningful:

Binding What it does
platform.query(sql, connection, params, timeout_seconds) Read-only SQL. Returns {columns, rows, row_count}, rows as dicts keyed by column name, under hard row and byte caps. timeout_seconds (default 120) is held to scripts.sql.max_timeout_seconds (300 unless lowered) and to the time left in the run; platform.execute takes it too. A statement Trino stops at its timeout is canceled and fails the run as the script's, naming the limit, the elapsed time and the query id; get_run reports slowest_statement (#2073)
platform.export(name, rows, format) Declares an output: rows as a list of dicts serialized in the declared format, or a string body written verbatim for a document. Formats: csv, json, jsonl, parquet, markdown, text, html, jsx — csv, json, jsonl and parquet require rows, and parquet types each column from its values by the rules a JSON-lines table is typed by, html and jsx take only a string body, markdown and text accept either. register={"connection": ...} makes the written file a table in the same call. references=[...] declares the mcp:// URIs and mcp:asset: references a portal document names, through manage_asset update on the asset it wrote, and the record lists any the body names that it did not declare under undeclared_references. In a draft run this reports the shape and size the output would have and writes nothing, unless the draft was run with allow_writes
platform.publish_data(name, data) Refreshes the data region of an existing dashboard without touching its markup: name is the same output identity platform.export uses and must already be an html, jsx, or markdown document of this script's; data (a dict or list) is serialized as JSON and structurally spliced into the one element matching #data. A document without the marked region fails the run. In a draft run this reports the payload size and writes nothing. See Refreshing a dashboard's data region
print(...) The run log, bounded; anything larger belongs in an export
platform.save_state(state) Replaces the one JSON object the script carries from run to run, bounded at 64 KiB. Applied when the run succeeds, in the write that marks it so, with a compare-and-set on the revision the run read: a failed run leaves the state alone, and a second run that read the same revision fails at its write naming the first. A draft reports what it would have saved
run.run_id, run.fire_time, run.params[...], run.state The frozen run record. run.state is the script's state as it stood when the run was created, {} for a script that has never saved any
json, date Encode/decode, and date arithmetic over YYYY-MM-DD strings

Parameters are bound, never spliced. Write :name placeholders and pass the values in params; the platform renders each as a typed SQL literal, including a list as a parenthesized IN list. Never build SQL by string concatenation. A date binds as a quoted string, so compare it against a DATE column as DATE :day, which renders the standard literal DATE '2026-08-12'.

A truncated result fails. platform.query refuses a result the engine truncated at the row cap rather than handing the script a partial answer, because a script that sums the first N rows of a larger result reports a wrong total with no sign that anything was missing. Aggregate in SQL, or narrow the query.

Determinism, precisely. Same script version + same parameters + same state read + same underlying data produce the same output. That is reproducibility, not "identical forever" — the warehouse changes between runs, and that is the point of re-running. There is deliberately no now() and no today(): the fire time is pinned on run.fire_time when the run is created, so a daily report recomputes "yesterday" identically when it is re-run months later to explain what it said. A script error is deterministic and is never retried; a rate-limit refusal of one of its calls is not a script error, and the host waits the refusal's interval within the run's deadline and issues the call again. An upstream's own 429 (and a 503 to a read) is waited on the same way, at most three times, before the script has the answer as data. A failed run carries cause (script, upstream, memory, worker_lost, platform, state_conflict) and retryable, true only when running it again is expected to succeed; a running run carries liveness and who holds it, and get lists the script's live_runs (#1859, #1860). A run holding more than scripts.worker.max_run_memory fails with cause memory, and run_draft and a run's metrics report peak_memory_bytes; platform.export(..., append=True) pages a large result into one output instead of holding it (#1861).

Scheduling. schedule_set gives a script a cadence: a cron expression (0 7 * * 1-5) or a descriptor (@daily), the IANA timezone it is read in, and the parameter values every fire binds. A script has at most one schedule, and setting one again replaces it. Bound values may contain ${fire_date}, which expands at each fire to that fire's date in the schedule's timezone and is written onto the run — which is what lets a scheduled run be re-read months later and explain itself. The schedule confers no authority: it decides when the latest saved version runs, never what it may reach. A fire arriving while the previous run is still going is skipped and recorded, a gap in service produces one run for the latest fire rather than a burst, and a failed scheduled run emails the script's owner. Full behavior in Running Managed Scripts.

One run at a time. By default runs of one script may overlap: a run_script run beside a scheduled one, or two run_script runs, and the state check at save time is the only guard. update with exclusive=true (shown by get) holds the script to one pending or running run whatever started it, enforced by the database: a scheduled fire while a run is open is recorded as skipped_overlap, and run_script (and a portal run) is refused naming the open run, with nothing queued. A skipped run carries reason on runs and get_run, naming the run it waited on, what started it and since when. See Running Managed Scripts (#1986).

Discoverable, and addressable. A script is reachable the way every other platform entity is: it has an mcp:script:<id> reference, search finds it by what it does, and fetch dereferences that reference to its contract — what it is, what it takes, whether a run would be admitted, its cadence — followed by the files it produced that the caller can open (each with its own reference, and a count of the ones they cannot, over its 50 most recent outputs, saying so when it wrote older ones) and its current source. The last successful run and the saved state are included for the owner and administrators only. The reference resolves by id, so renaming a script never breaks one that is stored. Discovery grants nothing: finding a script says it exists and what it takes, while running it is still run_script under the run gate. Search ranks every script in service for every signed-in caller (#2027), whether or not their persona allows manage_script, and skips dead ends (disabled, deprecated, superseded), though a reference to one still resolves and says plainly that it will not run.

Found by what it does, and by what its code says. A script is embedded through the shared index-jobs framework (source_kind = "scripts") as a set of chunks stored in script_embedding_chunks (#2027): its description card first (title, description, parameter names, tags, and the one line stating whether anything will execute it), then its source, comments included, cut where a definition, a load or a left-margin comment starts and sized to the embedder's input, so nothing is trimmed. Search scores each script by its best chunk. Ranking is hybrid: the semantic arm reaches a script whose wording nobody typed ("what refreshes the regional sales numbers" reaches daily-sales-report), and the lexical arm matches the card and the source, the card weighted above the source. A question whose answer lives only in the code ("which script writes sales.orders", "how is churn computed") is answered from the source. A deployment with no embedding provider ranks lexically. A created script and an edited one enqueue their own index job; any save that changes the card or the source, including a lifecycle change, which rewrites the card's last line, owes the script a rebuild, in which only the chunks whose text moved are embedded again (the script ranks on its previous chunks until it lands), and the reconciler re-embeds what a write could not enqueue, along with the whole corpus after a provider model swap. Search scores each script in service by its best chunk, exactly, rather than through a pool of nearest chunks. The single vector per script that preceded the chunk table (scripts.embedding and its HNSW index) is dropped by migration 000178 (#2029), a release after the one that introduced the chunks, so replicas on the earlier image kept working through the rollout.

Governance. Every mutation snapshots an immutable version with its author and the roles they held, which are the roles a run of that version presents. A saved script runs: run_script and a schedule execute the latest saved version, the run gate refuses only a disabled or retired script, and the persona filter authorizes every call at run time. A script is one person's: its owner sees it, edits it, runs it, and schedules it, and administrators do all four on every script. An administrator can also move a script to another owner from the portal, which re-captures the run identity from the administrator making the move. The security model is documented in full in Managed Scripts: Security Model.

manage_approval

manage_approval is the Approvals queue (#2437, #2494). A form is served to people who are not signed in (auth_mode public or bot_check) only at a version an administrator approved. Commands: submit (script, by id or the name of one of the caller's scripts; version, the latest by default; purpose, form_public; reason), which notifies each administrator once; list (the caller's own requests, or every one for an administrator; status, limit); get (id), which names what changed since the version approved before; and decide (id, decision approve or deny, comment), which notifies the requester. The request fixes the version by the sha256 of its source, and approving a version whose source changed is refused. Administrators decide every request, their own included (recorded as self_approved); anyone else is refused. A script run may list and read, never submit or decide. The portal's Approvals page and /api/v1/portal/approvals are the same queue. See Publishing a form.

manage_application

manage_application names an application built on the platform (an application database schema with its migrations, managed scripts with their endpoints and forms, webhook sources, portal assets) and releases it as a pinned set (#2411): from a release on, the scripts run and the assets serve the versions it pins, so a saved change goes live at the next release. Commands: create, get (with what a release would change, pending, and what it would refuse, problems), list, update, delete, add_part, remove_part, release, rollback, releases, export and import (#2412). Everyone signed in reads; the owner and administrators edit; administrators release, roll back, export and import. A release whose migration fails changes nothing; a rollback reverts migrations only when each has a down migration. An export is a bundle (a managed resource) naming the connections, secrets, webhook sources and bot checks it uses and carrying none of their values; an import is refused until map names a local one for each. See Applications.

show_scripts

Opens the user's managed scripts in the portal for the human to look at: what they own, what each one is scheduled to do, and how its recent runs went. Call it only when the human wants to see their scripts, schedules, or automations, or asks what ran and why something did not update ("show me my scripts", "did the daily report run"). It performs no data operation and returns a short confirmation with a link to the pages where the deployment is configured with its public address. For reading a script, its runs, or its log as part of your own work, use manage_script, which returns data and renders no UI. Optional search pre-focuses the pages.

The pages themselves are read-only and described in the portal guide. Approving a version stays on the admin surface. A script's source and version history are readable by everyone signed in (#1866), and so on the portal are its cadence and its run history as status, timing, cause and error (#1994); the capability grants, and what each run was given, printed and wrote, are the script's owner's and an administrator's to read, and one run is additionally readable in full by whoever requested it.


show_prompts

Renders the user's prompt library as an interactive browser for the human to look at. Call it only when the human wants to see, browse, or pick from their prompts visually ("show me my prompts", "open my prompt library"). It performs no data operation and returns only a short confirmation; the rendered app populates itself from its own manage_prompt calls. For running, creating, editing, or listing prompts as part of your own work, use manage_prompt, which returns data and renders no UI. Optional search pre-focuses the library.


Next Steps