Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/demo/public/summon.css
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,84 @@ nav.summon-nav a.summon-brand:hover {
.log .op-meta { color: var(--text-muted); }
.log .raw { color: var(--color-gray-400); }

.approval-stack {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 80;
display: grid;
gap: 10px;
width: min(360px, calc(100vw - 32px));
}

.approval-card {
display: grid;
gap: 8px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--background-default);
box-shadow: var(--shadow-elevated);
padding: 14px;
}

.approval-card span {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}

.approval-card strong {
color: var(--text-default);
font-size: 15px;
line-height: 1.25;
}

.approval-card p {
margin: 0;
color: var(--text-alt);
font-size: 12px;
}

.approval-card pre {
max-height: 120px;
margin: 0;
overflow: auto;
border: 1px solid var(--border-default);
border-radius: 6px;
background: var(--background-alt);
color: var(--text-alt);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.45;
padding: 8px;
white-space: pre-wrap;
}

.approval-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}

.approval-actions button {
min-width: 76px;
height: 32px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--background-default);
color: var(--text-default);
cursor: pointer;
font-weight: 700;
}

.approval-actions .approval-approve {
background: var(--background-inverse);
color: var(--text-inverse);
}

.summary {
padding: 12px 18px;
border-top: 1px solid var(--border-default);
Expand Down
44 changes: 37 additions & 7 deletions apps/demo/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
defineDataResource,
defineWorkerAction,
defineWorkerResource,
type ApprovalDecision,
type ApprovalRequest,
type CapabilityDefinition,
type CapabilityRegistry,
} from '@anarchitecture/summon';
Expand Down Expand Up @@ -55,6 +57,12 @@ const summonArgsSchema = z.object({

type SearchResult = z.infer<typeof searchResultSchema>;
type SummonArgs = z.infer<typeof summonArgsSchema>;
type PublishArgs = z.infer<typeof publishArgsSchema>;

interface PublishSummaryPlan {
title: string;
channel: string;
}

export interface DemoModelSelectionPayload {
modelProvider?: string | null;
Expand Down Expand Up @@ -87,6 +95,13 @@ export interface DemoHandlerOptions {
* streaming machinery needed to spawn sibling sandboxes.
*/
onSummon?: IntentHandler<SummonArgs>;
/**
* Optional because batch/demo surfaces may run without a visible host
* approval panel. Browser hosts should render their own approve/deny UI here.
*/
onApprovalRequest?: (
request: ApprovalRequest<PublishArgs, PublishSummaryPlan>,
) => Promise<ApprovalDecision> | ApprovalDecision;
}

export function createDemoCapabilityRegistry(
Expand Down Expand Up @@ -467,16 +482,31 @@ export function createDemoCapabilityRegistry(
'Ask the host for approval, then publish a titled summary only if approved. Use when the user explicitly asks to approve, confirm, publish, commit, send, update, or operate.',
argsSchema: publishArgsSchema,
stateShape:
'{published: boolean, publishedTitle: string | null, publishApprovalPending: boolean, publishApprovalApproved: boolean, publishApprovalDenied: boolean, publishApprovalError: string | null}',
'{published: boolean, publishedTitle: string | null, publishApprovalRequestId: string | null, publishApprovalPending: boolean, publishApprovalApproved: boolean, publishApprovalDenied: boolean, publishApprovalError: string | null}',
approval: {
request: ({ title }) => {
const approved = window.confirm(`Approve publishing "${title}"?`);
return approved ? 'approved' : { status: 'denied', reason: 'Demo approval denied' };
stateKeys: {
requestId: 'publishApprovalRequestId',
pending: 'publishApprovalPending',
approved: 'publishApprovalApproved',
denied: 'publishApprovalDenied',
error: 'publishApprovalError',
},
prepare: ({ title }) => ({
summary: `Publish "${title}"`,
details: { title, channel: 'demo-updates' },
plan: { title, channel: 'demo-updates' },
}),
request: ({ title }, request) => {
log(`-> approval requested "${title}"`);
if (request && opts.onApprovalRequest) return opts.onApprovalRequest(request);
return 'approved';
},
},
handler: ({ args, push }) => {
log(`-> publish_summary "${args.title}"`);
push({ published: true, publishedTitle: args.title });
handler: ({ args, approval, push }) => {
const plan = approval?.plan as PublishSummaryPlan | undefined;
const title = plan?.title ?? args.title;
log(`-> publish_summary "${title}"`);
push({ published: true, publishedTitle: title });
},
}),
];
Expand Down
99 changes: 99 additions & 0 deletions apps/demo/src/generate-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
} from '@anarchitecture/summon/engine';
import {
PolicyEngine,
type ApprovalDecision,
type ApprovalRequest,
type SurfacePolicy,
} from '@anarchitecture/summon';
import { createEventStore, type DevtoolsEvent } from '@anarchitecture/summon/devtools';
Expand Down Expand Up @@ -272,10 +274,105 @@ function logLine(cls: string, text: string) {
log.scrollTop = log.scrollHeight;
}

function requestHostApproval(request: ApprovalRequest): Promise<ApprovalDecision> {
logLine('op-meta', `approval pending: ${request.summary}`);
return new Promise((resolve) => {
const card = document.createElement('section');
card.className = 'approval-card';
card.dataset.approvalId = request.id;

const eyebrow = document.createElement('span');
eyebrow.textContent = request.capability;

const title = document.createElement('strong');
title.textContent = request.summary;

const meta = document.createElement('p');
meta.textContent = `Request ${request.id}`;

card.append(eyebrow, title, meta);

const details = formatApprovalDetails(request.details);
if (details) {
const detailsEl = document.createElement('pre');
detailsEl.textContent = details;
card.appendChild(detailsEl);
}

const actions = document.createElement('div');
actions.className = 'approval-actions';
const deny = document.createElement('button');
deny.type = 'button';
deny.textContent = 'Deny';
const approve = document.createElement('button');
approve.type = 'button';
approve.className = 'approval-approve';
approve.textContent = 'Approve';
actions.append(deny, approve);
card.appendChild(actions);

let settled = false;
const finish = (decision: ApprovalDecision) => {
if (settled) return;
settled = true;
pendingApprovalCards.delete(request.id);
card.remove();
if (approvalStack && approvalStack.childElementCount === 0) {
approvalStack.remove();
approvalStack = null;
}
resolve(decision);
};

approve.addEventListener('click', () => {
logLine('op-add', `approval approved: ${request.id}`);
finish('approved');
});
deny.addEventListener('click', () => {
logLine('op-error', `approval denied: ${request.id}`);
finish({ status: 'denied', reason: 'Demo approval denied' });
});

pendingApprovalCards.set(request.id, () => finish({ status: 'denied', reason: 'Approval request was replaced' }));
ensureApprovalStack().prepend(card);
});
}

function ensureApprovalStack(): HTMLElement {
if (approvalStack) return approvalStack;
approvalStack = document.createElement('div');
approvalStack.className = 'approval-stack';
document.body.appendChild(approvalStack);
return approvalStack;
}

function clearApprovalCards(reason: string): void {
const settleCards = [...pendingApprovalCards.values()];
pendingApprovalCards.clear();
for (const settle of settleCards) settle();
if (approvalStack) {
approvalStack.remove();
approvalStack = null;
}
if (settleCards.length > 0) logLine('op-error', reason);
}

function formatApprovalDetails(details: unknown): string {
if (details === undefined || details === null) return '';
if (typeof details === 'string') return details;
try {
return JSON.stringify(details, null, 2);
} catch {
return String(details);
}
}

let directions: DirectionInfo[] = [];
let ghostRoots: GhostRootInfo[] = [];
let modelProviders: ModelProviderInfo[] = [];
let defaultModelProviderId: string | null = null;
let approvalStack: HTMLElement | null = null;
const pendingApprovalCards = new Map<string, () => void>();
let showcaseScenarios: ShowcaseScenario[] = [...SHOWCASE_SCENARIOS];
let currentEffectiveSurfacePlan: SurfacePlan | null = null;
let currentShape: string | null = null;
Expand Down Expand Up @@ -1248,6 +1345,7 @@ function respawn(
active: ActiveContract = readActiveContract(),
initialHtml = '',
): SandboxHandle {
clearApprovalCards('Approval request was replaced');
if (componentIslands) {
componentIslands.destroy();
componentIslands = null;
Expand Down Expand Up @@ -1281,6 +1379,7 @@ function respawn(
modelSelection: readModelSelection,
onLog: (m) => logLine('op-add', m),
onError: (m) => logLine('op-error', m),
onApprovalRequest: requestHostApproval,
// summon needs DOM access (spawns a sibling iframe) and the streaming
// pipeline, so this page supplies the handler while the registry owns
// its prompt contract and schema validation.
Expand Down
40 changes: 40 additions & 0 deletions docs/adoption/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { z } from 'zod';
import {
createCapabilityRegistry,
defineAction,
defineApprovalAction,
defineDataResource,
} from '@anarchitecture/summon';

Expand Down Expand Up @@ -69,6 +70,45 @@ const capabilityContract = registry.toContract();
`capabilityContract.pack` is model-facing. `capabilityContract.validationCapabilities`
and `capabilityContract.initialState` are runtime-facing.

Approval actions are still host tools. The difference is that the host can
prepare the exact operation before asking for a decision. The generated surface
gets only small status state such as pending, approved, denied, failed, and a
request id; approve and deny controls stay in trusted host UI.

```ts
defineApprovalAction({
name: 'publish_summary',
description: 'Publish a prepared summary only after host approval.',
argsSchema: z.object({ draftId: z.string(), title: z.string() }),
stateShape: {
published: 'boolean',
publishedDraftId: 'string | null',
publishApprovalRequestId: 'string | null',
publishApprovalPending: 'boolean',
publishApprovalApproved: 'boolean',
publishApprovalDenied: 'boolean',
publishApprovalError: 'string | null',
},
approval: {
prepare: ({ draftId, title }) => ({
summary: `Publish "${title}"`,
details: { draftId },
plan: { draftId, endpoint: `/api/drafts/${draftId}/publish` },
}),
request: (_args, request) => approvalPanel.open(request),
},
handler: async ({ approval, push }) => {
const plan = approval!.plan as { draftId: string; endpoint: string };
await fetch(plan.endpoint, { method: 'POST' });
push({ published: true, publishedDraftId: plan.draftId });
},
});
```

Existing `approval.request(args)` callbacks remain valid. Hosts that need
durable approvals should persist the `ApprovalRequest` they receive in
`request`; Summon core intentionally does not add a workflow store.

## 2. Register Trusted Host Components

Trusted host components let the generated UI place a host-rendered component
Expand Down
6 changes: 6 additions & 0 deletions docs/adoption/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ requires a compatible host registry for the same reason.
- Use `defineWorkerAction` / `defineWorkerResource` for host-owned background
work and `defineApprovalAction` for operations that require a host approval
adapter before the handler runs.
- Treat approval as a workflow owned by the host, not a generated modal. For
approval actions, the host may `prepare` the exact operation into an
`ApprovalRequest`; the user approves or denies that request in host UI; the
approved handler executes from `ctx.approval.plan`. Summon core does not
persist approval requests, and generated surfaces should render only waiting,
approved, denied, or failed state.
- Proxy external data and assets through host handlers. The sandbox should see
validated state and data URLs, not credentials or network endpoints.
- Treat component definitions as trusted host code. Register only components
Expand Down
Loading
Loading