App table tools (app__<app>__<table>__<op>)
Tool names: app__<app>__<table>__{list,get,create,update,delete,aggregate}
Scopes: apps:invoke
When to use
Use the table tools to read or change the rows of a Sasha App without its view, for example to total a week's hours in a timesheet. If the app has an action for the job, prefer the action (the app-action family): it applies the app's own rules.
The tool name is app__<app>__<table>__<op>, with each - in the app id and the table name changed to _. <op> is list, get, aggregate, create, update or delete. For example, the table entries of the app timesheet has app__timesheet__entries__list, app__timesheet__entries__get and four more.
The tools are in the tool list only when all of these are true:
- The connection holds
apps:invoke, and the person hasuseormanageaccess on the app (the same rule as theopen-appfamily). - The app declares that it stores data, and the package validates.
- For
create,updateanddelete: the app declares its data asread-write. An app withreaddata has onlylist,getandaggregate.
Each table is either shared or owner-scoped, and the tool description says which:
- Shared: every user of the app sees the same rows, so a change reaches all of them.
- Owner-scoped: you see and change only the rows of the person who connected. Sasha sets the owner column of a new row to that person, and an
updatecannot change it.
Arguments for each operation:
list: optionalquery, withselect(up to 50 column names),where(up to 20 conditions offield,operatorandvalue),orderBy(up to 3 entries offieldanddirection,ascordesc),limit(1 to 200, default 50) andcursor. The operators areeq,neq,lt,lte,gt,gte,in,containsandstarts-with. The result isrows, andnextCursorwhen there are more rows. For the next page, putnextCursorinqueryascursor, and keep the rest ofquerythe same.get:id, the row's integer id. The result isrow.aggregate:aggregation, with optionalwhereandgroupBy(up to 3 columns), andmetrics: 1 to 10 named metrics, each one of{ "count": true },{ "countDistinct": "<column>" }, or{ "sum" | "avg" | "min" | "max": "<column>" }. The result isgroups. WithoutgroupBy, the metric values are also at the top level of the result.create:values(1 to 50 columns) andidempotencyKey. The result is the newrow.update:id,changes(1 to 50 columns),expectedVersionandidempotencyKey. The result is the changedrow.delete:id,expectedVersionandidempotencyKey. The result isdeleted: 1. A delete is permanent, with no undo and no history.
Each row has the table's columns and _sasha_version, an integer that goes up by one at each change. A list with select returns only the selected columns and _sasha_version. Pass it as expectedVersion to update and delete: the change is refused if the row changed after you read it.
idempotencyKey is 1 to 128 characters. Use a new key for each new change, and the same key only to retry the same change. The retry returns the first result and does not change data twice.
The result is in structuredContent, and the text is the same value as JSON.
Confirmation for a change
create, update and delete need a person to approve the change first, in the same way as an action that changes data (see the app-action family):
- A client that offers MCP form elicitation asks the person. If they decline, the call is refused with
APP_CONFIRMATION_DECLINED. - A client that does not offer form elicitation gets
APP_CONFIRMATION_REQUIRED. A retry gets the same answer. The person can make the change in the Sasha web interface, or from a client that offers form elicitation. - In the Sasha web interface, the first call returns a result that is not an error and changes nothing, with
structuredContent.confirmationholdingrequired: true, aconfirmationToken,expiresAtandeffects. After the person approves, call again with the same arguments and the sameidempotencyKey, plus the optionalconfirmationTokenargument. A token works once, for those exact arguments, for 10 minutes.
list, get and aggregate need no confirmation.
Example
Total the hours per project for the week that starts on 28 September 2026, with app__timesheet__entries__aggregate:
{
"aggregation": {
"where": [
{ "field": "date", "operator": "gte", "value": "2026-09-28" },
{ "field": "date", "operator": "lte", "value": "2026-10-04" }
],
"groupBy": ["project"],
"metrics": { "totalHours": { "sum": "hours" } }
}
}
The structured result (structuredContent):
{
"groups": [
{ "project": "Client onboarding", "totalHours": 7.5 },
{ "project": "Regional health review", "totalHours": 22 }
]
}
Then correct one entry with app__timesheet__entries__update. Read the row first with app__timesheet__entries__get to learn its _sasha_version:
{ "id": 412, "changes": { "hours": 7 }, "expectedVersion": 1, "idempotencyKey": "fix-entry-412-hours" }
After the person confirms:
{ "row": { "id": 412, "created_by": 14, "date": "2026-10-01", "project": "Regional health review", "hours": 7, "note": null, "_sasha_version": 2 } }
Refusals and what to do
An app tool refuses with an error result. The text is the error message, and structuredContent.error holds code, message, retryable, a diagnosticId to quote, and sometimes details.field.
APP_CONFLICT(app_conflict): the row changed after you read it. Callgetagain, check the new values, and retry with the new_sasha_versionand a newidempotencyKey.APP_RECORD_NOT_FOUND(app_record_not_found): there is no row with thatidthat you can see. In an owner-scoped table, rows of other people are not visible.APP_CONFIRMATION_REQUIREDandAPP_CONFIRMATION_DECLINED: see the confirmation section above.APP_INPUT_INVALID(app_input_invalid): the arguments do not match the schema, or a value or a query part is not valid, for example alimitabove 200 or anorderByentry withoutdirection.details.fieldnames the part that is wrong when it can.APP_FIELD_UNDECLARED(app_field_undeclared): a column name is not declared for the table, or names a column that you cannot change, such asid, the owner column of an owner-scoped table, or a_sasha_column. Read the table's columns from agetorlistresult.APP_IDEMPOTENCY_MISMATCH(app_idempotency_mismatch): theidempotencyKeywas used before with different arguments. Use a new key.APP_BUSY(app_busy): a call with the sameidempotencyKeyis still running. Wait, then retry with the same arguments.APP_ACCESS_DENIED(app_access_denied): the person no longer has access to the app.- If the connection does not hold
apps:invoke, or the app does not store data, or its data isreadonly, the tool is not in your tool list, and a call returnsMCP error -32602: Tool app__timesheet__entries__update not found(with the name you called). Read thepermissionssection ofgetDocs.
If an operation is missing, unclear or unsafe for the job, report one concrete issue with suggestImprovement. Do not include records, source or paths.
Related
- The
app-actionfamily runs the app's own actions, and explains confirmation in full. - The
open-appfamily opens the app's view. listAppFilesshows the app'sAPP.mdandschema.sql, for admin and staff users who manage the app.suggestImprovementreports a problem with a table tool.getDocshas thepermissionssection.