| name | notion-access |
| description | Vendor-neutral access layer for Notion. Every notion-* skill MUST delegate through this skill rather than invoking the Notion REST API or any Notion MCP directly. Resolves a substrate per operation in this order: (1) Notion MCP if authenticated and the configured prdDatabaseId is fetchable through it (identity-match), (2) curl + Bearer auth + internal-integration token. Verifies the active connection matches `.lisa.config.json` before every operation — substrates authenticated as a different Notion workspace are skipped, not used. |
| allowed-tools | ["Bash","Read","Skill"] |
Notion Access: $ARGUMENTS
Single chokepoint for all Notion operations. Routes each op to a substrate, enforces connection match, returns structured result. Caller skills (notion-*) MUST go through this — they MUST NOT call the Notion REST API or any mcp__*notion* tools directly.
Invocation contract
operation: read-page id: <uuid>
operation: create-page parent_database_id: <uuid> properties: {...} [children: [...]] # create a new page (e.g. a PRD row) in a database; children is optional — omit to create a page without initial block content
operation: write-page payload: {...} # update page properties
operation: archive-page id: <uuid>
operation: query-database id: <uuid> filter: {...} sort: {...}
operation: read-database id: <uuid>
operation: append-blocks page_id: <uuid> children: [...]
operation: search query: "..." [filter: { object: "page" }]
operation: list-users
operation: get-self
The skill returns either the structured operation result (JSON) or an error message prefixed with Error: and a remediation hint.
Workflow
Step 1 — Substrate selection (per operation)
Read config:
WORKSPACE=$(jq -r '.notion.workspaceId // empty' .lisa.config.json)
DB_ID=$(jq -r '.notion.prdDatabaseId // empty' .lisa.config.json)
[ -z "$WORKSPACE" ] && { echo "Error: notion.workspaceId not set. Run /lisa:setup:notion." >&2; exit 1; }
[ -z "$DB_ID" ] && { echo "Error: notion.prdDatabaseId not set. Run /lisa:setup:notion." >&2; exit 1; }
Probe each tier in order; the first that's ready AND identity-matches is the substrate for this operation. Identity-match is verified before any operation; substrates authenticated as a different workspace are skipped, not used.
substrate=""
if mcp_notion_can_fetch_database "$DB_ID"; then
substrate="mcp"
fi
read_notion_token() {
local workspace="$1"
[ -n "$NOTION_API_TOKEN" ] && { echo "$NOTION_API_TOKEN"; return; }
local slug=$(echo "$workspace" | tr '[:upper:]-' '[:lower:]_')
local varname="NOTION_API_TOKEN_${slug}"
[ -n "${!varname}" ] && { echo "${!varname}"; return; }
case "$(uname -s)" in
Darwin) security find-generic-password -s lisa-notion -a "$workspace" -w 2>/dev/null ;;
Linux) command -v secret-tool >/dev/null && \
secret-tool lookup service lisa-notion account "" 2>/dev/null ;;
MINGW*|MSYS*|CYGWIN*)
LISA_CRED_TARGET= powershell.exe -NoProfile -NonInteractive -Command 2>/dev/null | -d ;;
}
TOKEN=$(read_notion_token )
[ -n ];
me=$(curl -s -H -H \
)
me_workspace=$( | jq -r )
[ -n ] && [ = ];
:
[ -n ];
>&2
[ -z ];
plugin_enabled_global=$(jq -r ~/.claude/settings.json 2>/dev/null || )
plugin_enabled_project=$(jq -r .claude/settings.json 2>/dev/null || )
plugin_enabled_local=$(jq -r .claude/settings.local.json 2>/dev/null || )
>&2 <<
1
Step 2 — Connection-match assertion
The substrate selection in Step 1 already verifies identity. This step is the explicit re-assertion before any operation runs — defensive in case substrate state changed since selection. For the curl tier, re-validate token-to-workspace pairing if more than a few minutes elapsed.
The workspace identifier stored in config is whatever stable string the user picked at setup time — typically bot.workspace_name (human-readable) for simplicity. If the workspace has been renamed in Notion, setup-notion re-detects and re-stores; the access skill surfaces the mismatch instead of silently authing as the wrong workspace.
Step 3 — Operation dispatch
When $substrate=mcp, route through Notion MCP tools. When $substrate=curl, hit the Notion REST API directly. All curl calls use https://api.notion.com/v1/<path>, Notion-Version: 2022-06-28, Authorization: Bearer $TOKEN.
Substrate columns: try the column matching $substrate first. If that column is — for the requested operation (no adapter), fall through to the other substrate if it's also available. If neither has an adapter, the operation is unsupported.
| Operation | MCP adapter | curl adapter |
|---|
| Pages | | |
read-page id:<I> | mcp__claude_ai_Notion__notion-fetch | GET /v1/pages/<I> |
create-page parent_database_id:<D> properties:<P> [children:<arr>] | mcp__claude_ai_Notion__notion-create-pages | POST /v1/pages body { "parent": { "database_id": "<D>" }, "properties": <P>, "children": <arr?> } (children optional per Notion API) |
write-page payload:<P> | mcp__claude_ai_Notion__notion-update-page | PATCH /v1/pages/<I> body { "properties": {...}, "archived": true/false } |
archive-page id:<I> | mcp__claude_ai_Notion__notion-update-page (with archived: true) | PATCH /v1/pages/<I> body { "archived": true } |
append-blocks page_id:<P> children:<arr> | (no direct equivalent) | PATCH /v1/blocks/<P>/children body { "children": <arr> } |
| Databases | | |
read-database id:<I> | mcp__claude_ai_Notion__notion-fetch | GET /v1/databases/<I> |
query-database id:<I> filter:<F> sort:<S> | mcp__claude_ai_Notion__notion-search (with collection scope) | POST /v1/databases/<I>/query body { "filter": <F>, "sorts": <S>, "page_size": <N> } |
| Comments | | |
list-comments block_id:<I> | (MCP lacks a generic list-comments tool) | GET /v1/comments?block_id=<I> |
create-comment page_id:<I> rich_text:<arr> | mcp__claude_ai_Notion__notion-create-comment (page-level) |
Operations not in this table are unsupported — add an adapter row before invoking. Adapters MUST return parsed JSON; never raw HTTP responses.
Step 4 — Return result
Wrap the JSON response in a <result> block for caller parsing. On HTTP non-2xx, prefix the error message with Error: and surface the HTTP status code plus Notion's response body verbatim.
exec_op() {
local method="$1" path="$2" body="${3:-}"
local args=( -s -X "$method"
-H "Authorization: Bearer $TOKEN"
-H "Notion-Version: 2022-06-28" )
[ -n "$body" ] && args+=( -H "Content-Type: application/json" --data-binary "$body" )
local code=$(curl "${args[@]}" -o /tmp/notion-resp -w "%{http_code}" \
"https://api.notion.com/v1${path}")
if [ "${code:0:1}" != "2" ]; then
echo "Error: Notion API $method $path returned HTTP $code" >&2
cat /tmp/notion-resp >&2
return 1
fi
cat /tmp/notion-resp
}
Invariants
- Caller skills never call
curl https://api.notion.com/... or any mcp__*notion* tool directly. They invoke this skill via the Skill tool with an operation name and arguments.
- Substrate is selected per skill invocation following the tier ladder. The first tier that's available AND identity-matches
notion.workspaceId wins.
- The connection-match check is mandatory at every tier. Skipping it (because "the user obviously meant this workspace") is forbidden — silent cross-workspace operations are exactly the multi-account hazard this design exists to prevent.
- API tokens never mutate. If the configured workspace's token is wrong or missing, fail loudly and tell the user to run
/lisa:setup:notion.
Notion-Version is pinned to 2022-06-28 — the version every existing notion-* skill targets. Bumping it is a coordinated change across the access skill and all callers.
Headless behavior
In a headless / non-interactive context (no TTY, CI=true, or -p mode), the MCP tier is unavailable (its OAuth flow needs a browser). The ladder collapses to curl + NOTION_API_TOKEN. Same skill code runs identically; only the substrate changes.
Per-page sharing prerequisite
Notion integrations only see pages that have been explicitly shared with them. If read-page or query-database returns a 404 or object_not_found error and the configured workspace is correct, the cause is almost always that the page/database wasn't shared with the integration. Surface this in the error message:
Page not visible to the integration. Open the page in Notion → "..." menu → Connections → add the lisa integration.
Do not paper over with a retry. Sharing is a one-time human action per database (or per page if the user prefers page-level sharing); failures here mean the user needs to act.