CtrlK
BlogDocsLog inGet started
Tessl Logo
SkillPublic

sharing

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.

Quality

86%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

Sharing — Private by Default, Explicit Share

Rule

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.

Concepts

Three visibility levels

  • private — owner + explicit share grants only. Default.
  • org — owner + explicit grants + anyone in the same org. Members can view by default; set-resource-visibility with role lets them comment or edit. That role is stored as the share grant to the resource's own org, so it is removed when the resource leaves org.
  • public — owner + explicit grants + anyone with the link. They can view by default; set-resource-visibility with role: "commenter" lets signed-in people with the link comment (never edit). That role is a share row for the link principal, which is never listed or shared as a person, and it is removed when the resource leaves public. 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).

Roles on a share grant

  • 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.

Share roles apply to one resource; organization roles apply to a team; app roles apply within one app. These are separate permissions. For role setup, use agent-native-docs to find the current organization and app-role guide.

Before adding an admin gate, identify which role system protects that operation and use its existing role descriptor and permission checks. Do not replace role checks with an email comparison or a custom isAdmin column.

Anonymous public URLs stay separate

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.

Make a resource ownable

In your template's server/db/schema.ts:

import { sql } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import {
  createSharesTable,
  ownableColumns,
} from "@agent-native/core/db/schema";

export const decks = pgTable("decks", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  data: text("data").notNull(),
  createdAt: text("created_at").notNull().default(sql`now()`),
  updatedAt: text("updated_at").notNull().default(sql`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,
});

Add agentRead: { actions: ["get-deck"] } naming the MCP tool that reads one resource, so the share panel can send it to Claude, Claude Code and Codex.

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.

Restricting public visibility and cross-org user shares

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. A public or cross-org-shared resource with that access could let an unrelated user act with the viewer's credentials.

Defaults match historical behaviour: allowPublic: true, requireOrgMemberForUserShares: false. Resources that don't set the flags work as before.

Reusable organization groups

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. For these registrations, a direct resource-level admin share does not override that manager policy; it grants the editor role for access checks, while only the creator and callback-approved managers can change visibility or manage share grants.

Filter list/read queries

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.

Guard write actions

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.

Check many resources at once

Never call resolveAccess in a loop over a list: each call runs several queries, so a page that checks every row pays that per row. When rows come from somewhere accessFilter cannot reach (a manifest, a gateway response), use resolveAccessMany("deck", ids, ctx, { skipResourceBody: true }). It returns a Map of the ids the viewer can open, makes the same decision as resolveAccess, and reads rows, shares, org membership, and group membership once. Give a registration that uses canManageAccess a canManageAccessKey (for example resource.orgId) so the manager check is asked once per key, not once per row.

Create actions must set owner

When inserting a new row, fill ownerEmail and orgId from the request context:

import { fail } from "@agent-native/core/action";
import {
  getRequestUserEmail,
  getRequestOrgId,
} from "@agent-native/core/server/request-context";

const ownerEmail = getRequestUserEmail();
// Never fall back to a shared sentinel owner; require an authenticated session.
if (!ownerEmail) fail("Not authenticated", { statusCode: 401 });

await db.insert(schema.decks).values({
  id: crypto.randomUUID(),
  title,
  data,
  ownerEmail,
  orgId: getRequestOrgId(),
  // visibility defaults to 'private'
  // ...
});

Drop in the share UI

import { ShareButton } from "@agent-native/toolkit/app/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.

Standard share surface

All app share popovers use one single-panel surface, top to bottom:

  1. The invite field: one input for people, organization groups, and the org's agents, a role picker, and Invite.
  2. People with access: the owner, people, groups, and agents in one list. Roles read "Can view", "Can comment", "Can edit", "Can manage access"; Remove access lives in each row's role menu.
  3. General access: Only people invited / Anyone at / Anyone with the link. Anyone at has a role picker (Can view / Can comment / Can edit) and Anyone with the link has one (Can view / Can comment); the grants behind them are not listed under People. Changes save at once; only a type registered with confirmOrgVisibility: true (chats) asks before going from private to the organization.
  4. A footer with Copy link (shareUrl) and a Send to menu.

Rules:

  • Use the text-only ShareTrigger from @agent-native/toolkit/sharing.
  • Never split people and agents into separate tabs or sections. Agents that can hold their own access are org service principals (svc-<name>@service.<orgId>); list-resource-shares returns them as agents for managers, and they are shared as ordinary user principals. Agents that connect over MCP or OAuth act as the person who connected them, so they get no row: put ways to hand the resource to them in Send to.
  • Send to (sendTo) holds every handoff: openInAgent (Claude, Claude Code, Codex), resource-specific items, copyAgentPrompt, and openMcpSettings. Do not add another agent panel or tab.
  • Declare agentRead: { actions: ["get-<thing>"] } (plus idParam when the tool's id argument is not id) on the registration. The panel then builds Open in Claude, Claude Code and Codex and Copy agent prompt itself: the prompt sends the agent to this app's MCP server to call that tool with the person's own permissions. Pass sendTo.openInAgent only when the app needs a different prompt. Agent-readable resources also get Copy agent link.
  • When supportsGroupShares is enabled, groups come from the same invite field's autocomplete. Do not add a separate group-management surface.
  • App-specific tabs (shareTabs) may remain when they represent a real domain action (for example, an embed-code editor).

Actions available everywhere

The framework auto-mounts these actions in every template — no per-template boilerplate:

ActionArgsPurpose
share-resourceresourceType, 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-resourceresourceType, resourceId, principalType, principalIdRevoke access.
list-resource-sharesresourceType, resourceIdCurrent visibility + all share grants.
set-resource-visibilityresourceType, resourceId (or resourceIds, up to 100), visibility, role?Change to private / org / public. With org, role (viewer / commenter / editor) sets what members can do; with public, role (viewer / commenter) sets what signed-in people with the link can do. With resourceIds, every resource is checked first and none change if one can't; one approval covers the call.

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.

To re-skin the share notification email for the whole app, register overrideTransactionalEmail("core.resource-shared", render) from a server plugin. Do not fork share-resource or add another per-resource hook: the registration's getBrandName / getLogoUrl / getSender / getHeroHtml / getShareEmailExtras hooks still feed the default template and the override's props. See /docs/deployment#email-templates.

Migration pattern for existing tables

When retrofitting an existing resource table:

  1. Add owner_email, org_id, visibility columns (defaults 'local@localhost', NULL, 'private').
  2. Backfill owner_email from any prior creator trail; otherwise leave the default.
  3. Add the companion {type}_shares table.
  4. Register via registerShareableResource.
  5. Update list/read actions to use accessFilter.
  6. Update update/delete actions to assertAccess with the correct role.
  7. Add <ShareButton> to the resource header.
  8. Add getResourcePath in the registration so agent-triggered shares can email a direct link even when no UI supplied resourceUrl.

Templates that opt out

Sharing doesn't apply to:

  • Personal-data apps (mail) — user-scoped by design.
  • External source-of-truth data — apply the access policy enforced by the upstream system.
  • Demo/boilerplate (starter) — no resources.

For these, add a short note to the template's AGENTS.md explaining why.

Debugging

  • ForbiddenError from an action means the current user isn't owner / hasn't been shared / can't meet the role bar.
  • If the agent can't see a resource it just created, check that the insert actually set owner_email from the request context.
  • If a share doesn't take effect in the UI, confirm the template's list-* action uses accessFilter — the share rows are there but nothing is reading them yet.
Repository

BuilderIO/agent-native

Path

.agents/skills/sharing/SKILL.md

Last updated
First committed

Is this your skill?

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.