Framework-level sharing and privacy for user-authored resources (dashboards, documents, forms, decks, etc.). Use when making a resource table ownable, wiring list/read/update access checks, or dropping the standard share dialog into a template.
65
81%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Any resource a user creates (dashboards, documents, forms, decks, compositions, booking links, issues, analyses) is private to the creator by default and visible to others only when they have been explicitly shared with or when the creator changes visibility to org or public.
This is the framework-level primitive. Every ownable resource gets it for free — same API, same UI, same skill.
Workspace apps are the intentional organization-scoped exception: their default
visibility is org, configurable by an organization owner/admin. The creator is
stored as the app owner, and the creator or an organization owner/admin can
change the app's visibility or manage its share grants.
private — owner + explicit share grants only. Default.org — owner + explicit grants + anyone in the same org (read-only).public — owner + explicit grants + anyone with the link (read-only). Public docs do NOT appear in other users' list/sidebar/search results — accessFilter omits them by default. They're reachable by id (resolveAccess admits them) so direct links and SSR routes like /p/:id keep working. If a list endpoint legitimately needs cross-user public discovery (a template gallery, etc.), pass accessFilter(table, shares, ctx, minRole, { includePublic: true }).Visibility is coarse. Explicit share grants are fine-grained (per user, reusable organization group, or per org).
viewer — read only.commenter — read + add comments, but cannot edit the resource or manage shares.editor — read + write.admin — read + write + manage shares. Does NOT replace the single owner_email on the resource.There are three role systems and they never imply one another. A share role answers "what may this person do to one row". An org role (org_members.role) answers "what may this person do to the team". An app role (defineAppRoles, see the authentication skill) answers "what may this person do inside one app". A share admin is not an app admin and neither is an org admin.
Form "publish" slugs, booking-link slugs, any feature that exposes a URL to unauthenticated users — these are a different axis and are NOT controlled by the sharing system. Keep them alongside it.
In your template's server/db/schema.ts:
import {
table,
text,
integer,
now,
ownableColumns,
createSharesTable,
} from "@agent-native/core/db/schema";
export const decks = table("decks", {
id: text("id").primaryKey(),
title: text("title").notNull(),
data: text("data").notNull(),
createdAt: text("created_at").notNull().default(now()),
updatedAt: text("updated_at").notNull().default(now()),
...ownableColumns(), // adds owner_email, org_id, visibility
});
export const deckShares = createSharesTable("deck_shares");Then register it in server/db/index.ts (not the schema file — keeps the schema file free of the getDb closure and avoids circular imports):
// server/db/index.ts
import * as schema from "./schema.js";
import { createGetDb } from "@agent-native/core/db";
import { registerShareableResource } from "@agent-native/core/sharing";
export const getDb = createGetDb(schema);
export { schema };
registerShareableResource({
type: "deck",
resourceTable: schema.decks,
sharesTable: schema.deckShares,
displayName: "Deck",
titleColumn: "title",
getResourcePath: (deck) => `/deck/${deck.id}`,
getDb,
});The type string is the stable id the UI and actions use. getDb is required — the framework-level share actions use it to reach your template's DB.
Some resources should NOT be reachable by an arbitrary authenticated user even with the link, and should NOT be shareable to an email outside the org. Two optional registration flags lock these axes down:
registerShareableResource({
type: "extension",
// ...
allowPublic: false, // hides "Public" in the share dialog and rejects it server-side
requireOrgMemberForUserShares: true, // user shares must target an org member or pending invitee
supportsGroupShares: true, // enable reusable organization groups for this resource
});allowPublic: false — set-resource-visibility('public') throws ForbiddenError, accessFilter / resolveAccess treat any stored 'public' row as private (defense in depth against bad data), and the share popover hides the "Public" option. list-resource-shares returns policy.allowPublic: false so the UI follows the server.requireOrgMemberForUserShares: true — share-resource looks up principalId in org_members and org_invitations (pending) for the resource's orgId and rejects user shares to anyone else. The same flag also pins principalType: "org" shares to the resource's own org — sharing to a different org would let that org's members run code in the viewer's auth context (same threat model as a public extension). (The flag name is kept for backward compatibility; treat it as "lock both user and org shares to the resource's org".)Use both for resources that execute code or expose privileged data with the viewer's credentials. Extensions ship with both set: an extension's HTML calls actions / SQL / the secrets-injecting proxy as the viewer, so a public or cross-org-shared extension would let a stranger run arbitrary code with someone else's auth context. scripts/guard-extension-no-public.mjs (CI + pnpm prep) statically enforces that the extension registration keeps both flags set.
Defaults match historical behaviour: allowPublic: true, requireOrgMemberForUserShares: false. Resources that don't set the flags work as before.
The workspace organization model stores reusable groups in
workspace_user_groups. Organization owners/admins manage the groups and their
members through the existing group actions. Set supportsGroupShares: true on a
resource registration to enable principalType: "group" in the generic share
actions and the shared popover's autocomplete. Group ids are always checked
against the resource's organization, and membership is evaluated at access time
so group changes apply to existing shares.
Workspace apps opt into this flag and use the organization default visibility
setting. The app creator is the durable owner_email, while organization
owners/admins receive admin access through the registration's
canManageAccess callback.
import { accessFilter } from "@agent-native/core/sharing";
const rows = await db
.select()
.from(schema.decks)
.where(accessFilter(schema.decks, schema.deckShares));accessFilter admits rows the current user owns, has been shared on, or that the user can reach via org visibility. public rows are NOT admitted by default — see the visibility section above for why and how to opt in.
import { assertAccess } from "@agent-native/core/sharing";
export default defineAction({
schema: z.object({ id: z.string(), title: z.string() }),
run: async (args) => {
await assertAccess("deck", args.id, "editor");
// ...proceed
},
});For delete actions use "admin" (or fold in "owner" to require the real owner).
authorize is a different axis, not an alternative: it gates whether the caller may run the operation at all, while assertAccess scopes which row they may touch. A write action restricted to some teammates needs both — authorize: appAccess.requireAny(...) on the action, assertAccess inside run.
When inserting a new row, fill ownerEmail and orgId from the request context:
import {
getRequestUserEmail,
getRequestOrgId,
} from "@agent-native/core/server/request-context";
const ownerEmail = getRequestUserEmail();
// Never fall back to a sentinel like "local@localhost" — that pools every
// unauthenticated write into one shared tenant (see the 2026-04-29 leak and
// guard-no-localhost-fallback). Throw / 401 when there is no session instead.
if (!ownerEmail) throw new Error("Not authenticated");
await db.insert(schema.decks).values({
id: nanoid(),
title,
data,
ownerEmail,
orgId: getRequestOrgId(),
// visibility defaults to 'private'
// ...
});import { ShareButton } from "@agent-native/core/client/sharing";
// In the resource's header/toolbar:
<ShareButton
resourceType="deck"
resourceId={deck.id}
resourceTitle={deck.title}
/>;For list views, show <VisibilityBadge visibility={row.visibility} /> next to each resource.
All app share popovers should use the same compact surface contract:
ShareTrigger from @agent-native/toolkit/sharing.ShareCopyRow, which exposes a Copy action without
printing the raw URL.ShareAgentsSection only when the resource has a real agent-readable
link or prompt. Keep it collapsed by default and supply domain-specific
content through the shared section shell.supportsGroupShares is enabled, keep the existing people input as the
only entry point and include organization groups in its autocomplete. Do not
add a separate group-management surface to each resource.ShareDisclosureSection is the toolkit-owned shell for optional expandable
share details; use its ShareAgentsSection or SharePeopleSection wrappers
instead of creating another collapsible share panel in a template.
The framework auto-mounts these actions in every template — no per-template boilerplate:
| Action | Args | Purpose |
|---|---|---|
share-resource | resourceType, resourceId, principalType, principalId, role, notify?, resourceUrl?, message? | Grant a user, group, or org access. notify defaults to true for individual user shares; resourceUrl can provide the direct app link and message an optional short note for the notification email. |
unshare-resource | resourceType, resourceId, principalType, principalId | Revoke access. |
list-resource-shares | resourceType, resourceId | Current visibility + all share grants. |
set-resource-visibility | resourceType, resourceId, visibility | Change to private / org / public. |
Both the agent and the UI use these same actions. The agent calls them as tools;
UI code should use ShareButton / ShareDialog or the action client hooks
instead of hand-writing route calls.
When retrofitting an existing resource table:
owner_email, org_id, visibility columns (defaults 'local@localhost', NULL, 'private').owner_email from any prior creator trail; otherwise leave the default.{type}_shares table.registerShareableResource.accessFilter.assertAccess with the correct role.<ShareButton> to the resource header.getResourcePath in the registration so agent-triggered shares can email a direct link even when no UI supplied resourceUrl.Sharing doesn't apply to:
For these, add a short note to the template's AGENTS.md explaining why.
Dashboards and analyses in the analytics template currently live in the settings KV store (u:<email>:dashboard-* keys), not SQL. Sharing requires either migrating them to SQL tables (then applying this skill) or extending the settings store with a parallel share overlay. This is a tracked follow-up — see the analytics template's AGENTS.md.
ForbiddenError from an action means the current user isn't owner / hasn't been shared / can't meet the role bar.owner_email from the request context.list-* action uses accessFilter — the share rows are there but nothing is reading them yet.bb282b1
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.