plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md
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. Per the credential-substrate-precedence contract, resolves a substrate per operation in this order: (1) curl + Bearer auth + internal-integration token when the token is present and identity-matches the configured workspace, (2) Notion MCP as fallback if authenticated and the configured prdDatabaseId is fetchable through it. Verifies the active connection matches `.lisa.config.json` before every operation — substrates authenticated as a different Notion workspace are skipped, not used.
npx skillsauth add codyswanngt/lisa lisa-notion-accessInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
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.
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. The ordering is the shared credential-substrate-precedence contract — the configured-provider token substrate leads, the interactive MCP is the fallback — not a Notion-local choice. Identity-match is verified before any operation; substrates authenticated as a different workspace are skipped, not used, at every tier.
substrate=""
# Tier 1: curl + API token — the configured-provider substrate, resolved through
# lisa-secrets-access. Leads because it is identical on a laptop, in CI, in a cloud
# routine, and in a subagent, and because its workspace binding travels with the
# request instead of coming from ambient browser-session state.
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; }
# Preferred path: the single secrets chokepoint. It owns the one-store rule
# and the surface ladder, so anything it can answer must not be read out of an
# OS keychain here — a second reader is how the same credential ends up living
# in two places and drifting.
#
# Ordered across trusted machine-managed substrates, ending at the installed
# package. Checkout-local paths are deliberately absent: a familiar generated
# destination is still repository-controlled executable code. The plugin
# rungs are the floor: `resolve-secret.mjs` ships beside this skill, so a rung
# pointing at it is reachable from anywhere the plugin itself is installed.
# Without one, a consumer repository that vendors none of the leading paths
# never reaches a resolver at all — the ladder exits without having asked
# anything, which is what pushed agents into improvising their own credential
# lookups. This LADDER is identical in every skill that resolves a credential
# and `credential-resolver-ladder` fails if any copy diverges. Only what
# happens AFTER the ladder may differ between them.
# Execute only machine-managed plugin/package resolvers. Checkout-local
# candidates are repository-controlled code, not trusted merely by path.
local candidates=()
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
candidates+=("$CLAUDE_PLUGIN_ROOT/skills/lisa-secrets-access/scripts/resolve-secret.mjs")
fi
if [ -n "${PLUGIN_ROOT:-}" ]; then
candidates+=("$PLUGIN_ROOT/skills/lisa-secrets-access/scripts/resolve-secret.mjs")
fi
# Last rung deliberately needs no environment variable: an agent that was
# never handed a plugin root still has the installed package to fall back on.
candidates+=(node_modules/@codyswann/lisa/plugins/lisa/skills/lisa-secrets-access/scripts/resolve-secret.mjs)
local resolver
local tried=()
for resolver in "${candidates[@]}"; do
tried+=("$resolver")
if [ -f "$resolver" ]; then
local via_lisa
via_lisa=$(node "$resolver" get NOTION_API_TOKEN 2>/dev/null) \
&& [ -n "$via_lisa" ] && { echo "$via_lisa"; return; }
# Empty/error means this substrate had no answer; try the next trusted one.
fi
done
# Legacy fallback: the OS keychain written by the guided /lisa:setup:notion
# flow, for projects that have not adopted a credentials provider. Reached only
# when the chokepoint is absent or has no entry.
#
# This rung is REMOVED ON 2026-11-01 — a dated migration ramp, not a standing
# exemption (see credential-substrate-precedence, "Legacy OS-keychain fallback
# — removal date"). A keychain entry is machine-local ambient state no headless
# surface can reach, so a project resting on it has no working tier 1 in cron,
# CI, or a cloud session. Re-run /lisa:setup:notion before that date to store
# NOTION_API_TOKEN through the chokepoint instead.
local from_keychain=""
case "$(uname -s)" in
Darwin) from_keychain=$(security find-generic-password -s lisa-notion -a "$workspace" -w 2>/dev/null) ;;
Linux) command -v secret-tool >/dev/null && \
from_keychain=$(secret-tool lookup service lisa-notion account "$workspace" 2>/dev/null) ;;
MINGW*|MSYS*|CYGWIN*)
# `cmdkey /generic ... /pass:` stores the secret in Windows Credential Manager, but
# `cmdkey /list` never prints stored passwords (by design). Read the CredentialBlob
# back via the Win32 CredRead API through PowerShell; pass the target name via an env
# var to dodge nested quoting, and strip the CRLF powershell.exe appends.
from_keychain=$(LISA_CRED_TARGET="lisa-notion-${workspace}" powershell.exe -NoProfile -NonInteractive -Command '
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public static class LisaCred {
[StructLayout(LayoutKind.Sequential)]
private struct CREDENTIAL {
public int Flags; public int Type; public IntPtr TargetName; public IntPtr Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist;
public int AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName;
}
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
private static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
[DllImport("advapi32.dll")] private static extern void CredFree(IntPtr cred);
public static string Read(string target) {
IntPtr p;
if (!CredRead(target, 1, 0, out p)) { return null; }
try {
CREDENTIAL c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));
if (c.CredentialBlobSize == 0) { return String.Empty; }
return Marshal.PtrToStringUni(c.CredentialBlob, c.CredentialBlobSize / 2);
} finally { CredFree(p); }
}
}
"@
[LisaCred]::Read($env:LISA_CRED_TARGET)' 2>/dev/null | tr -d '\r') ;;
esac
[ -n "$from_keychain" ] && { echo "$from_keychain"; return; }
# Name every path. A bare `return 1` sends the next reader hunting for a
# resolver they cannot see the absence of; the enumeration turns that into a
# seconds-long diagnosis. Paths and store coordinates only — never any
# resolved value, on any path.
echo "Error: could not resolve NOTION_API_TOKEN through lisa-secrets-access or the legacy keychain." >&2
echo "Tried, in order (relative paths are from $PWD):" >&2
printf ' %s\n' "${tried[@]}" >&2
echo " <OS keychain> service=lisa-notion account=$workspace" >&2
return 1
}
TOKEN=$(read_notion_token "$WORKSPACE")
if [ -n "$TOKEN" ]; then
# Verify token belongs to the configured workspace.
me=$(curl -s -H "Authorization: Bearer $TOKEN" -H "Notion-Version: 2022-06-28" \
"https://api.notion.com/v1/users/me")
me_workspace=$(echo "$me" | jq -r '.bot.workspace_name // .bot.workspace_id // empty')
if [ -n "$me_workspace" ] && [ "$me_workspace" = "$WORKSPACE" ]; then
substrate="curl"
elif [ -n "$me_workspace" ]; then
# A present-but-wrong token fails the gate rather than deferring to the MCP.
# Silently succeeding through an MCP authenticated elsewhere is the exact bug
# the precedence contract exists to surface.
echo "Warning: Notion token belongs to workspace '$me_workspace' but config declares '$WORKSPACE'. Skipping curl tier." >&2
fi
fi
# Tier 2: Notion MCP — first-class fallback, used when tier 1 is genuinely
# unavailable (no token, no curl adapter for the operation, or Notion API outage).
# Identity-matched by fetching the configured PRD database.
# Pseudo-code; actual call is the MCP tool invocation.
# Try to fetch DB_ID through the MCP. Success → MCP is authed to the right workspace.
# 404 / object_not_found → MCP is authed elsewhere (or unauthenticated). Skip.
if mcp_notion_can_fetch_database "$DB_ID"; then
: ${substrate:=mcp}
# Mark the MCP available even when curl already won tier 1 — the dispatch table
# falls through to it for operations curl has no adapter for.
mcp_available=true
fi
# Fail loudly with actionable remediation if nothing works.
if [ -z "$substrate" ]; then
# Detect plugin enablement state for the suggestion.
plugin_enabled_global=$(jq -r '.enabledPlugins["notion@claude-plugins-official"] // false' ~/.claude/settings.json 2>/dev/null || echo "false")
plugin_enabled_project=$(jq -r '.enabledPlugins["notion@claude-plugins-official"] // false' .claude/settings.json 2>/dev/null || echo "false")
plugin_enabled_local=$(jq -r '.enabledPlugins["notion@claude-plugins-official"] // false' .claude/settings.local.json 2>/dev/null || echo "false")
cat >&2 <<EOF
Error: no Notion access substrate available for workspace '$WORKSPACE'.
Attempted (in credential-substrate-precedence order):
curl — no NOTION_API_TOKEN found for $WORKSPACE (env, slug-suffixed env, or keychain) OR token belongs to a different workspace
MCP — $([ "$plugin_enabled_global" = "true" ] || [ "$plugin_enabled_project" = "true" ] || [ "$plugin_enabled_local" = "true" ] && echo "plugin enabled but not authenticated or cannot fetch configured prdDatabaseId" || echo "plugin not enabled in any settings.json scope")
Remediation paths (the first is the contract's primary path):
1. Provision an internal-integration API token — works headless, in CI, and in
multi-workspace setups, and is the substrate this project resolves first.
Run /lisa:setup:notion — guided flow with clipboard-piped keychain store.
2. Install the Notion MCP plugin (local scope — per-developer, gitignored).
The supported fallback when no credentials provider is configured.
Run in your terminal:
jq '.enabledPlugins["notion@claude-plugins-official"] = true' \\
.claude/settings.local.json 2>/dev/null > /tmp/s && \\
mv /tmp/s .claude/settings.local.json || \\
echo '{"enabledPlugins":{"notion@claude-plugins-official":true}}' > .claude/settings.local.json
Then restart Claude Code (or run /restart-mcp) to load the plugin, and
invoke 'mcp__plugin_notion_notion__authenticate' to complete OAuth.
Also share the configured prdDatabaseId with the integration via
the page's '•••' menu → Connections.
EOF
exit 1
fi
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.
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) | POST /v1/comments body { "parent": { "page_id": "<I>" }, "rich_text": <arr> } |
| create-comment-on-block block_id:<I> rich_text:<arr> | mcp__claude_ai_Notion__notion-create-comment (with block anchor) | POST /v1/comments body { "parent": { "block_id": "<I>" }, "rich_text": <arr> } |
| Search & users | | |
| search query:<Q> [filter:<F>] | mcp__claude_ai_Notion__notion-search | POST /v1/search body { "query": "<Q>", "filter": <F or null> } |
| list-users | — | GET /v1/users |
| get-self | — | GET /v1/users/me |
Operations not in this table are unsupported — add an adapter row before invoking. Adapters MUST return parsed JSON; never raw HTTP responses.
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
}
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.credential-substrate-precedence contract — internal-integration token first, Notion MCP as fallback. The first tier that's available AND identity-matches notion.workspaceId wins. Do not restate or locally override the ordering here./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.In a headless / non-interactive context (no TTY, CI=true, or -p mode), the MCP tier is unavailable (its OAuth flow needs a browser) and the ladder collapses to curl + NOTION_API_TOKEN — which is already tier 1 interactively. That is the point of the ordering: headless and interactive sessions take the same primary path, so a credential problem reproduces on a laptop instead of only in cron (credential-substrate-precedence, "headless parity"). Same skill code runs identically; only the availability of the fallback changes.
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 <id> 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.
development
Prepare a machine — a fresh laptop or a throwaway container — to run coding agents, before any repository exists. Detects which of Lisa's supported agents (Claude Code, Codex, Cursor, OpenCode, Antigravity, Copilot) are already installed, asks which credential manager the machine uses (Bitwarden, 1Password, Doppler, Vault, AWS, or none), and installs only what is missing, each by its vendor's own preferred method. Idempotent, headless by default, and emits a Dockerfile for a spin-up/spin-down environment. Run it on a new machine, in a container, or before cloning anything.
tools
Provision and verify a remote execution environment for a host project — Codex Cloud today, other remote surfaces as they are added. Generates a repository-owned setup script that installs the declared toolchain, materializes secrets through lisa-secrets-access, and runs the project's own hook. Provisions by API where one exists, by driving the vendor console where one does not, and by emitting exact config otherwise — then proves the result with the same read-back regardless of which tier did the work. Use before dispatching any work with executionEnv.
tools
Bring a developer's machine in line with the toolchain the project declares. Reports every tool in remoteEnv.tools that is missing, outdated, or unpinned for this platform, and installs the missing ones into ~/.local/bin from the same pinned, checksummed entries the remote surfaces use — but only when asked. Same manifest, same pins, same installers as lisa-setup-remote-env; what differs is consent and that the pin is a floor rather than an equality. Run it on a fresh checkout, after a manifest change, or when a tool fails at the moment of use.
tools
Route one unit of work to a remote execution surface. Reads the executionEnv parameter (local by default, codex-cloud or claude-web today), verifies the environment is provisioned and bound to this repository, submits a thin skill invocation, records the task identifier to .lisa/remote-dispatch.json, and exits without polling. Routing only — the remote runs the identical skill from the identical repository. Composable and inline: other skills invoke it via the Skill tool rather than users calling it directly.