every plugin route renders a workspace, and it is not a rule you can forget

an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:47:48 +00:00
co-authored by Claude Opus 5
parent 2e3c935da6
commit 543e88a9a6
38 changed files with 1026 additions and 440 deletions
@@ -32,7 +32,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
<p className="mt-1">
Go to the{' '}
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/projectcreate"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
@@ -43,32 +48,56 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/apis/library"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
API Library
</a>
. Search for and enable each of these:
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li><strong>Gmail API</strong></li>
<li><strong>Google Calendar API</strong></li>
<li>
<strong>Gmail API</strong>
</li>
<li>
<strong>Google Calendar API</strong>
</li>
</ul>
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
<p className="mt-1">
Click each one, then click <strong>Enable</strong>.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/branding"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Branding
</a>
.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Set <strong>App name</strong> to your organization name or &quot;Officer&quot;</li>
<li>Set <strong>User support email</strong> to your admin email</li>
<li>Add your admin email under <strong>Developer contact information</strong></li>
<li>Click <strong>Save</strong></li>
<li>
Set <strong>App name</strong> to your organization name or &quot;Officer&quot;
</li>
<li>
Set <strong>User support email</strong> to your admin email
</li>
<li>
Add your admin email under <strong>Developer contact information</strong>
</li>
<li>
Click <strong>Save</strong>
</li>
</ul>
</li>
@@ -76,7 +105,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/audience"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
OAuth Audience
</a>
.
@@ -86,7 +120,8 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
If your team uses Google Workspace, select <strong>Internal</strong> — no verification needed
</li>
<li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong>{' '}
(required while the app is unverified; limit of 100 test users)
</li>
</ul>
</li>
@@ -95,7 +130,12 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/scopes"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
@@ -103,18 +143,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => (
<li key={s.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{s.scope}
</code>{' '}
— {s.description}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">{s.scope}</code> —{' '}
{s.description}
</li>
))}
</ul>
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
<p className="mt-1">
Click <strong>Update</strong>, then <strong>Save</strong>.
</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code>{' '}
is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as{' '}
<strong>restricted</strong> by Google. This is fine for Internal apps (Google Workspace) and External apps
in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
</li>
@@ -122,13 +164,20 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
<a
href="https://console.cloud.google.com/auth/clients"
target="_blank"
rel="noopener noreferrer"
className="text-duck-teal underline"
>
Clients
</a>
, then click <strong>Create OAuth client</strong>.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Application type: <strong>Web application</strong></li>
<li>
Application type: <strong>Web application</strong>
</li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li>
<li>
Authorized redirect URIs: add{' '}
@@ -136,14 +185,17 @@ const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
{redirectUri}
</code>
</li>
<li>Click <strong>Create</strong></li>
<li>
Click <strong>Create</strong>
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
<p className="mt-1">
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste
them into the fields below.
</p>
</li>
</ol>
@@ -170,7 +222,7 @@ const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVer
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
{status.valid ? 'Credentials valid' : (status.error ?? 'Invalid credentials')}
</span>
</div>
);