
Docyrus App Ai Tools
- 27 installs
- 13 repo stars
- Updated July 15, 2026
- docyrus/agent-skills
Create and manage app-scoped AI tools for the Docyrus base assistant Docy using the docyrus apps ai-tools CLI, across four executable tool types.
About
A skill for authoring app-owned AI tools (data_source_query, custom_query, secure_exec, client_side) that become available to Docyrus's base assistant when the app installs. A developer uses it to give Docy new tools and set app-level agent context.
- Four tool types spanning fixed data-source reads, read-only SQL, sandboxed JS, and client-side actions
- App-scoped tools auto-attach to the base assistant with no per-agent wiring
Docyrus App Ai Tools by the numbers
- 27 all-time installs (skills.sh)
- Ranked #9,601 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/docyrus/agent-skills --skill docyrus-app-ai-toolsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 27 |
|---|---|
| repo stars | ★ 13 |
| Last updated | July 15, 2026 |
| Repository | docyrus/agent-skills ↗ |
What it does
Create and manage app-scoped AI tools for the Docyrus base assistant Docy using the docyrus apps ai-tools CLI, across four executable tool types.
Files
Docyrus App-Scoped AI Tools
Build custom tools that the Docyrus system base assistant ("Docy") can call during a conversation. Tools are created per app (tenant_ai_tool, ownership=CUSTOM, tenant_app_id=<app>) with the docyrus apps ai-tools CLI. Once the app is installed in the tenant, its tools are automatically available to the base assistant — there is no per-agent wiring.
End-to-end workflow
1. Create the tool — docyrus apps ai-tools create with the right --type and that type's config. 2. Guide the base assistant (optional but recommended) — docyrus apps set-agent-context to tell "Docy" when to use which tool.
App-scoped tools are owned by the app and exclusive to the system base assistant ("Docy"). When the app is installed in the tenant, its tools are attached to Docy automatically — you do not (and cannot) wire them to a specific agent, and there is no agent tools attach step. If the app is not installed in the tenant, its tools are not loaded.
App-scoped tools cannot be attached to custom AI agents. Giving a custom agent its own tools is a different, agent-owned flow (docyrus agent tools …) outside this skill's scope.All commands need an authenticated CLI session (docyrus auth who to verify). The app is selected with exactly one of --appId or --appSlug on every command.
The four tool types — pick one
--type | Use when | Executes | Author config |
|---|---|---|---|
data_source_query | Read/list records from one data source; the LLM only supplies filter values | Server, RLS-enforced | data source id + fixed columns/limit/formulas + a filter template with {{param}} bindings → data-source-query-tool.md |
custom_query | Read across joins/aggregations needing hand-written read-only SELECT SQL | Server, read-only txn, RLS | a Handlebars-templated SQL string → custom-query-tool.md |
secure_exec | Multi-step logic, calling the Docyrus REST API (incl. writes), transforming/compacting results | Server sandbox (10s, no FS/env, Docyrus-API-only network) | a JavaScript body → secure-exec-tool.md |
client_side | The action must run in the user's browser/app (UI navigation, local selection, host APIs) | Client/frontend | input/output schema only; the host app implements the handler → client-side-tool.md |
Read the matching reference file before authoring that type — each documents the exact config fields, templating/binding rules, runtime behavior, and worked examples.
docyrus apps ai-tools command surface
Routes to /v1/dev/apps/:appId/ai-tools. The CLI resolves --appSlug to an app id.
docyrus apps ai-tools list --appSlug <slug>
docyrus apps ai-tools get --appSlug <slug> --toolId <id>
docyrus apps ai-tools create --appSlug <slug> --type <type> [config flags | --from-file payload.json]
docyrus apps ai-tools update --appSlug <slug> --toolId <id> [flags | --from-file payload.json]
docyrus apps ai-tools delete --appSlug <slug> --toolId <id>Convenience flags (camelCase flag → snake_case payload key)
Common to every type:
| Flag | Key | Notes |
|---|---|---|
--name | name | Required on create. Display name. |
--key | key | Required on create. The function name the LLM sees. snake_case, stable, and globally unique (a DB UNIQUE constraint across all tenants) — namespace it (e.g. crm_get_customer_balance) to avoid collisions. |
--description | description | Drives LLM tool selection — always write a clear, specific one. |
--type | type | Must be set to data_source_query \ |
--inputJsonSchema | input_json_schema | Required at runtime for all four types (the LLM's argument schema). A no-argument tool still needs {"type":"object","properties":{}}. |
--outputJsonSchema | output_json_schema | Optional result schema (mainly client_side). |
--icon | icon | Optional. |
--environments | environments | Comma list of web,desktop,ios. Restricts where the tool is offered. |
--needsApproval | needs_approval | Require user approval before the call runs. Use for tools that mutate data. |
--dynamicApprovalFormula | dynamic_approval_formula | JSONata that decides approval per-call. |
Type-specific flags (--secureExecCode, --customQuerySqlQuery, --customQueryFilters, --dataSourceQueryDataSourceId, --dataSourceQueryColumns, --dataSourceQueryFilters, --dataSourceQueryFilterKeyword, --dataSourceQueryFormulas, --dataSourceQueryChildQueries, --dataSourceQueryLimit, --clientSideExecution) are documented in each type's reference file.
--from-file <path> / --data '<json>' send a raw JSON payload (snake_case keys); convenience flags are merged over it. Prefer `--from-file` for anything with JSON schemas, SQL, or code — it sidesteps shell quoting. JSON-typed flags (--inputJsonSchema, --dataSourceQueryFilters, …) expect a JSON string when passed inline.
The endpoint forcesownership=CUSTOMandtenant_app_id. Platform-managed fields (group,avatar,restricted,cost,development_status,core_action_id,core_data_provider_id,owner_product_id) are not settable here.
Set the app's agent context
agent_context is app-level guidance text injected into the base assistant's prompt. Use it to orchestrate: name each tool's key and say when to reach for it, what each returns, and any ordering ("look up the customer with get_customer before calling get_customer_balance").
docyrus apps set-agent-context --appSlug <slug> --from-file agent-context.md # recommended for prose
docyrus apps set-agent-context --appSlug <slug> --value "Use get_customer_balance when the user asks about balances or overdue amounts."
docyrus apps set-agent-context --appSlug <slug> --clear # remove itProvide exactly one of --value, --from-file, or --clear. (This writes agent_context via PATCH /v1/dev/apps/:appId; docyrus apps update --agentContext does the same.)
Authoring checklist
--typeset, and the matching config provided (see the type's reference file).input_json_schemapresent (even if empty) and describing only what the LLM should supply.descriptionis specific enough for the model to choose the tool correctly;keyissnake_caseand stable.- The app is installed in the tenant — that's what surfaces its tools to the base assistant (Docy). App-scoped tools auto-attach to Docy only; they're never wired to custom agents.
- Mutating tools (
secure_execwrites, etc.) consider--needsApproval true. - Agent context mentions the new tool's
keyand trigger conditions. - Verify with
docyrus apps ai-tools get --toolId <id>, then exercise it by chatting with the base assistant (docyrus docy "...").
client_side tool
A tool with no server-side execution. When the agent calls it, the AI SDK surfaces the call to the client (the web/desktop/mobile host app), which runs its own handler and returns the result into the conversation. Use for actions that only make sense in the user's environment: navigate the UI, open a modal/record, read the current selection, render a custom component, request input, or call a host/device API.
There is no sandbox and no Docyrus-side execution — the host app must implement a handler for the tool's key. If the app has no handler registered, the call will not resolve.
Config fields
| Flag | Key | Required | Meaning |
|---|---|---|---|
--type | type | Yes | Set to client_side. |
--clientSideExecution | client_side_execution | Yes | true. This boolean is what actually registers the tool as client-handled. |
--inputJsonSchema | input_json_schema | Yes | The arguments the LLM produces. Without it the tool is skipped at runtime. |
--outputJsonSchema | output_json_schema | No | Shape the client returns — declare it so the model knows what it gets back. |
--description | description | Strongly recommended | Drives tool selection; a missing one is logged as degrading quality. |
--environments | environments | No | Limit to web / desktop / ios where a handler exists. |
Set bothtype=client_sideandclient_side_execution=true. Do not also give it an action — a tool with both an action andclient_side_executionskips client registration (the action path wins).
How it runs
1. The agent decides to call the tool and emits arguments matching input_json_schema. 2. The AI SDK yields the tool call to the client instead of executing server-side. 3. The host app's handler (keyed by the tool key) performs the action and returns a result (ideally matching output_json_schema). 4. The result is fed back to the agent to continue the turn.
Authoring the skill only creates the contract (key + schemas + description). The handler implementation lives in the front-end app and is out of scope for the CLI.
Example: open_record
{
"name": "Open record",
"key": "open_record",
"type": "client_side",
"client_side_execution": true,
"description": "Opens a record's detail page in the current app UI so the user can see or edit it. Call after you have identified the exact record the user wants to view.",
"environments": ["web", "desktop"],
"input_json_schema": {
"type": "object",
"properties": {
"dataSourceSlug": { "type": "string", "description": "Slug of the data source" },
"recordId": { "type": "string", "description": "Record id to open" }
},
"required": ["dataSourceSlug", "recordId"]
},
"output_json_schema": {
"type": "object",
"properties": { "opened": { "type": "boolean" } }
}
}docyrus apps ai-tools create --appSlug crm --from-file open-record.jsonOnce the app is installed in the tenant, the tool is available to the base assistant automatically — make sure the web app registers an open_record handler for it. Mention in the agent context when to call it (e.g. "use open_record only after confirming the record with the user").
custom_query tool
Runs a read-only SQL SELECT you hand-write, rendered from a Handlebars template with the LLM's arguments and tenant/user context. Use when a single data source query isn't enough — joins, aggregations, window functions, CTEs.
Runtime: the template is compiled once, rendered per call, then executed inside a read-only DbUtils.transaction (Postgres rejects any DML/DDL at the engine) with RLS enforced and a 15s timeout. Only SELECT / WITH … SELECT is allowed; the query is wrapped as SELECT * FROM (<your sql>) _q LIMIT <limit> OFFSET <offset>. Result returned to the LLM:
{ "type": "custom_query_result", "data": [ /* rows */ ], "meta": { "count": <n> } }Config fields
| Flag | Key | Required | Meaning |
|---|---|---|---|
--customQuerySqlQuery | custom_query_sql_query | Yes | Handlebars-templated SELECT. |
--inputJsonSchema | input_json_schema | Yes | LLM argument schema. The model may also supply limit/offset (numbers) — include them in the schema if you want the model to page; otherwise they default to limit 1000 (max 50000), offset 0. |
--customQueryFilters | custom_query_filters | No | Author "inline" filters exposed as {{FILTERS.<field>}}; the LLM cannot override a field an inline filter already claims. |
Template variables
| Variable | Value |
|---|---|
INPUT | The LLM's validated arguments object. {{INPUT.customerId}}. |
FILTERS | Merged inline + LLM filters keyed by field → { operator, field, value }. {{FILTERS.status.value}}. |
TENANT_ID, TENANT_SCHEMA | Current tenant id / schema name. |
USER_ID, USER_EMAIL, USER_FIRSTNAME, USER_LASTNAME, USER_FULLNAME | The acting user. |
{{q <value>}} helper | Renders a safe single-quoted SQL string literal, or NULL for null/undefined. |
SQL-injection contract (read carefully)
Inputs are sanitized by doubling single quotes only. That is only safe when every interpolated value is a quoted string literal. Two safe forms:
WHERE name = '{{INPUT.name}}' -- explicit quotes
WHERE name = {{q INPUT.name}} -- q helper quotes + handles NULLNever bare-interpolate identifiers, numbers, or operators (WHERE id = {{INPUT.id}}, SELECT {{INPUT.col}} …, ORDER BY {{INPUT.sort}}). The read-only transaction limits blast radius (no writes/DDL) but does not stop data exfiltration via crafted input. For numeric inputs, validate the range/type in input_json_schema and still wrap or cast explicitly.
Example: revenue_by_month
revenue-by-month.json:
{
"name": "Revenue by month",
"key": "revenue_by_month",
"type": "custom_query",
"description": "Total paid invoice revenue grouped by month for a given year. Pass the 4-digit year.",
"input_json_schema": {
"type": "object",
"properties": { "year": { "type": "integer", "minimum": 2000, "maximum": 2100 } },
"required": ["year"]
},
"custom_query_sql_query": "SELECT date_trunc('month', paid_on) AS month, sum(amount) AS revenue FROM invoices WHERE status = 'paid' AND extract(year FROM paid_on) = {{q INPUT.year}}::int GROUP BY 1 ORDER BY 1"
}docyrus apps ai-tools create --appSlug finance --from-file revenue-by-month.jsonNote {{q INPUT.year}}::int — the value is rendered as a quoted literal then cast, never bare-interpolated. Tenant scoping is automatic via RLS; add WHERE tenant_id = {{q TENANT_ID}} only if the table isn't already RLS-scoped.
data_source_query tool
Reads from one data source. The tool author fixes every aspect of the query shape; the LLM only supplies simple scalar filter parameters declared in input_json_schema. Those values are bound into the author's filter template. The model cannot pass raw filters, columns, limits, offsets, formulas, or aggregations.
Runtime: DataSource.readData inside an RLS-enforced transaction. Result returned to the LLM:
{ "type": "data_source_query_result", "data": [ /* rows */ ], "meta": { "count": <n> } }Contents
- Config fields
- Parameter binding in filters (`{{param}}`)
- Full-text keyword search (`filter_keyword`)
- Filter rule shape & operators
- Columns, limit, formulas, child queries
- Worked example: `get_customer_balance`
Config fields
| Flag | Key (data_source_query_*) | Required | Meaning |
|---|---|---|---|
--dataSourceQueryDataSourceId | ..._data_source_id | Yes | UUID of the tenant_data_source to read. Find it with docyrus studio list-data-sources --appSlug <slug> (returns id + slug). |
--inputJsonSchema | input_json_schema | Yes | The scalar filter params the LLM supplies. Required even if empty ({"type":"object","properties":{}}). Keep params simple: customerId (uuid), searchQuery (text), startYear (number/date). |
--dataSourceQueryFilters | ..._filters | No | Author filter template (IQueryFilterGroup). Binds params via {{param}}; see below. |
--dataSourceQueryFilterKeyword | ..._filter_keyword | No | Full-text search keyword. A static string, or an LLM param bound via a whole {{param}} token; see below. |
--dataSourceQueryColumns | ..._columns | No | Fixed projection. String "a,b,c" or string array. Default "*" (all fields). |
--dataSourceQueryLimit | ..._limit | No | Fixed row cap. Default 1000, hard max 50000. Values ≤ 0 are ignored. |
--dataSourceQueryFormulas | ..._formulas | No | Fixed formulas object keyed by alias ({ "<alias>": { ...formula } }). |
--dataSourceQueryChildQueries | ..._child_queries | No | Fixed array of child (sub-record) queries; each entry needs a non-empty `alias`. |
Offset is always 0 and there is no LLM-driven pagination, ordering, calculations, or pivot — those are intentionally not exposed for this tool type. If you need them, use custom_query.
Parameter binding in filters
A filter rule references an input parameter by setting its value to the exact token {{paramName}} (the whole value — not embedded like %{{q}}%). At call time:
- The token is replaced with the parameter's value, keeping its native JSON type (uuid string, number, boolean, array for
in). - If the parameter is absent —
null,undefined,"", or[]— the rule is dropped, and any group left with no rules is pruned. So an omitted optional parameter simply means "don't filter on this". 0andfalseare real values and are kept.- Rules without a token are static and always apply — use them for tenant/owner/status scoping that the LLM must never be able to drop.
This is what makes optional filters work: declare params as optional in input_json_schema, add one bound rule per param, and the agent filters by whichever it provides.
Full-text keyword search
data_source_query_filter_keyword runs a full-text search across the data source's text content (the same filterKeyword the REST query payload accepts), independent of the structured filters above. It uses the same parameter binding as filters, but it is a single string rather than a rule tree:
- Set it to the exact token
{{paramName}}to bind the LLM's value. As with filters, partial interpolation (%{{q}}%) is not supported — the whole value must be the token. - The bound value is coerced to a string (full-text search is text-only).
- If the parameter is absent —
null,undefined,"", or[]— the keyword is dropped (no keyword filter applied). So an omitted optional param means "don't keyword-search". - A plain string with no token is a static keyword that always applies.
Typical use: declare an optional searchQuery param and set data_source_query_filter_keyword to "{{searchQuery}}". The agent gets a free-text search lever without being able to shape the query. Combine it with structured filters to mix scoped filtering and keyword search in one tool.
Filter rule shape & operators
data_source_query_filters is an IQueryFilterGroup:
{
"combinator": "and",
"rules": [
{ "field": "status", "operator": "=", "value": "active" },
{ "field": "customer_id", "operator": "=", "value": "{{customerId}}" },
{ "field": "name", "operator": "like", "value": "{{searchQuery}}" }
]
}combinator:"and"|"or". Groups can nest (rulesmay contain other groups).field: a column/field slug on the data source.operator: comparison= != <> > < >= <= between; textlike "starts with" "ends with"; collectionin "not in" "contains any" "contains all"; null"is null" "not null" empty "not empty"; plus date operators (today,last_7_days,this_month,before_today, …) and user/role/team operators.betweenandintake arrayvalues.- For column-to-column comparison use
valueFieldinstead ofvalue(not parameterizable).
Columns, limit, formulas, child queries
These are author-fixed. When you set explicit columns, the aliases of any formulas / child_queries are appended automatically so their computed values are still projected. Leave columns unset for "*".
The detailed shapes for filters, formulas, childQueries, and pivot match the REST data-source query payload. If the docyrus-api-dev skill is available, its references/data-source-query-guide.md is the full reference for those shapes.
Worked example: get_customer_balance
Two optional params; either filters or both.
get-customer-balance.json:
{
"name": "Get Customer Balance",
"key": "get_customer_balance",
"type": "data_source_query",
"description": "Returns customer balance rows. Filter by customerId for one customer, and/or by searchQuery to match the customer name. With no arguments, returns active customers' balances.",
"data_source_query_data_source_id": "8f1c…-uuid-of-customer_balances-ds",
"data_source_query_columns": "customer_id,customer_name,balance,overdue_amount",
"data_source_query_limit": 200,
"input_json_schema": {
"type": "object",
"properties": {
"customerId": { "type": "string", "format": "uuid", "description": "Restrict to one customer" },
"searchQuery": { "type": "string", "description": "Case-insensitive match on customer name" }
}
},
"data_source_query_filters": {
"combinator": "and",
"rules": [
{ "field": "status", "operator": "=", "value": "active" },
{ "field": "customer_id", "operator": "=", "value": "{{customerId}}" },
{ "field": "customer_name","operator": "like", "value": "{{searchQuery}}" }
]
}
}docyrus apps ai-tools create --appSlug crm --from-file get-customer-balance.jsonBehavior: customerId only → status=active AND customer_id=<id>. searchQuery only → status=active AND customer_name like <q>. Neither → status=active. The status=active rule always applies.
secure_exec tool
Runs a sandboxed JavaScript body when the agent calls the tool. Use for multi-step logic, calling the Docyrus REST API (including writes), and shaping/compacting results before they reach the model. The most flexible tool type — and the only one that can mutate data.
Runtime: the secure-exec Node sandbox (NodeRuntime). Your body runs as an ES module (/script.mjs), so top-level `await` works — no wrapper function needed. On success the LLM receives:
{ "type": "secure_exec_result", "data": <your exports>, "durationMs": <n>, "stdout": "<captured>" }If the script throws (or exits non-zero), the LLM instead receives an error envelope — not a secure_exec_result:
{ "type": "error", "error": "Script execution failed: <message>" }Config fields
| Flag | Key | Required | Meaning |
|---|---|---|---|
--secureExecCode | secure_exec_code | Yes | The JS body. Prefer --from-file code.js then send the file's text, or include it in a --from-file payload.json. |
--inputJsonSchema | input_json_schema | Yes | Schema for the data object the LLM produces. Required even if empty. |
Consider --needsApproval true for tools that create/update/delete records.
Sandbox limits & policy
| Limit | Value (AI tool) |
|---|---|
| Wall-clock timeout | 10 s (hardcoded for AI tools) |
| Memory | 128 MB (max 256) |
| Code size | 512 KB |
record + data | 1 MB serialized |
| stdout / stderr | 1 MB each |
Denied by default: filesystem, child processes, environment variables, and all outbound network except the tenant's Docyrus API host (fetch/http/DNS are allowlisted to that host only). There is no opt-in to external network for AI tools.
Limit notes (from SCRIPT_LIMITS / secureExecTool.ts):
- The 10 s timeout is hardcoded for AI tools; the underlying engine default is 30 s (max 60 s), but AI tools always pass 10 s.
- Memory and the 256 MB cap are engine defaults; AI tools don't override memory, so they run at 128 MB.
record+dataare size-checked combined (1 MB total serialized).- stdout/stderr are capped at 1 MB each, then silently truncated.
stderrnever reaches the LLM (operator logs only).
Globals available to the script
| Name | Value |
|---|---|
data | The LLM's arguments (validated against input_json_schema). Injected as a const — mutate its properties, but don't reassign data itself. |
record | Always {} for AI tools. Also a const. |
api.ds | Pre-authenticated Docyrus data API (bearer token is closure-captured — unreadable from user code). The only authenticated path. |
console.log / console.error | Captured into stdout / stderr. stderr is logged for operators but not sent to the LLM. |
fetch | The real global, but permission-gated to the API host only and unauthenticated — the token lives in api.ds's closure, so a raw fetch to the API gets 401. Prefer api.ds; reach for fetch only for unauthenticated endpoints on the same host. |
| Standard JS & web APIs | Full ES built-ins (Date, Math, JSON, Promise, async/await) plus the web-style globals the Node-compatible isolate provides — TextEncoder/TextDecoder, URL/URLSearchParams, structuredClone, atob/btoa, queueMicrotask, timers, etc. Anything that touches the filesystem, child processes, env vars, or non-API network is gated by the permission layer and throws if attempted, regardless of how it's reached. |
api.ds methods (all async; return parsed JSON when the response is application/json, otherwise the raw text body; throw on non-2xx as API request failed: <status> <statusText> - <body>):
| Call | REST endpoint |
|---|---|
api.ds.list(appSlug, dsSlug, params?) | GET /apps/:appSlug/data-sources/:dsSlug/items?… |
api.ds.get(appSlug, dsSlug, id, params?) | GET …/items/:id |
api.ds.create(appSlug, dsSlug, payload) | POST …/items |
api.ds.update(appSlug, dsSlug, id, payload) | PATCH …/items/:id |
api.ds.delete(appSlug, dsSlug, id) | DELETE …/items/:id |
params is a plain object; object values (e.g. filters, sort) are JSON-stringified into the query string, matching the REST query payload.
Returning a result
Assign to `exports` — that object is the tool result data. If you set nothing you get {}. Keep returns small and structured: the LLM pays tokens for everything you return, so map raw API rows down to the fields it needs.
exports.count = items.length;
exports.items = items.map(i => ({ id: i.id, name: i.name }));Example: find_overdue_invoices
input_json_schema:
{ "type": "object",
"properties": {
"daysOverdue": { "type": "number", "minimum": 1 },
"customerId": { "type": "string" }
},
"required": ["daysOverdue"] }secure_exec_code:
const cutoff = new Date(Date.now() - data.daysOverdue * 86400000).toISOString();
const filters = { status: { eq: "open" }, dueDate: { lt: cutoff } };
if (data.customerId) filters.customerId = { eq: data.customerId };
const invoices = await api.ds.list("finance", "invoices", {
filters, limit: 50, sort: [{ field: "dueDate", direction: "asc" }],
});
const rows = invoices.data || [];
exports.count = rows.length;
exports.totalDue = rows.reduce((s, i) => s + (i.amount || 0), 0);
exports.invoices = rows.map(i => ({ id: i.id, customer: i.customerName, amount: i.amount, dueDate: i.dueDate }));Create it (code from a file keeps quoting sane):
docyrus apps ai-tools create --appSlug finance \
--name "Find overdue invoices" --key find_overdue_invoices --type secure_exec \
--needsApproval false \
--inputJsonSchema "$(cat schema.json)" \
--secureExecCode "$(cat find-overdue.js)"Or put everything (including secure_exec_code as a JSON string) in one --from-file payload.json.