diff --git a/apps/web/src/agent-deploy.ts b/apps/web/src/agent-deploy.ts
index b90f04588..045736ffe 100644
--- a/apps/web/src/agent-deploy.ts
+++ b/apps/web/src/agent-deploy.ts
@@ -8,6 +8,7 @@ import type { ToolEffect } from "@corbits/worker/definition-json";
import type { McpServerDeployment } from "@corbits/worker/workflow-ids";
import { ensureAgentHubCredential } from "./agent-hub-credential";
+import { assignRolesToAgent, resolveAgentRoleIds } from "./agent-roles";
import { personMailAddress } from "./mail-address";
import { listMcpServers, resolveWorkspaceTenantId, toMcpServerDeployment } from "./mcp-servers";
import type { McpServer } from "./mcp-servers";
@@ -74,6 +75,11 @@ export type NewAgentInput = {
/** Tool permissions to re-declare on a redeploy, as creator-sourced grant
* requirements. */
readonly toolEffects?: readonly ToolEffect[];
+ /** Role names checked in the create form, resolved against this tenant's
+ * live roles and assigned to the agent's workflow principal once the
+ * install lands. An unknown name fails the deploy before anything
+ * installs; a missing principal (no first run yet) fails it after. */
+ readonly roles?: readonly string[];
/** Deploy anew even when nothing changed (a restart mints a fresh run). */
readonly redeploy?: boolean;
};
@@ -183,6 +189,16 @@ export async function deployAgentSource(
if (!isValidSlug(slug)) {
throw new AgentDeployError("this name doesn't produce a usable agent address");
}
+ // Fail fast on an unknown role name: resolving before anything installs
+ // leaves no half-deployed agent behind. An empty selection skips the read.
+ let roleIds: readonly string[] = [];
+ try {
+ roleIds = await resolveAgentRoleIds(args.tenantId, args.input.roles ?? [], fetchImpl);
+ } catch (cause) {
+ throw new AgentDeployError(
+ cause instanceof Error ? cause.message : "resolving this agent's roles failed",
+ );
+ }
const assetName = agentDeploySourceAssetName(slug);
const packageName = `@workbench-agent/${slug}`;
@@ -247,5 +263,24 @@ export async function deployAgentSource(
if (args.input.schedule !== undefined) {
await scheduleAgentRun(args.tenantId, args.input.schedule, assetName, tenant.domain, fetchImpl);
}
+ // The install already landed, so a missing principal (no first run yet)
+ // or a refused write fails the deploy with the agent in place — the panel
+ // stays open with this message, and resubmitting assigns the roles.
+ if (roleIds.length > 0) {
+ try {
+ await assignRolesToAgent(
+ {
+ tenantId: args.tenantId,
+ roleIds,
+ principalRef: { deploymentId: parsed.deploymentId, address: triggerAddress },
+ },
+ fetchImpl,
+ );
+ } catch (cause) {
+ throw new AgentDeployError(
+ cause instanceof Error ? cause.message : "assigning this agent's roles failed",
+ );
+ }
+ }
return parsed;
}
diff --git a/apps/web/src/agent-roles-query.ts b/apps/web/src/agent-roles-query.ts
new file mode 100644
index 000000000..5fe851d7e
--- /dev/null
+++ b/apps/web/src/agent-roles-query.ts
@@ -0,0 +1,21 @@
+// The roles a create form can offer: the tenant's live platform roles,
+// read through react-query so the agent and workbench create surfaces share
+// one loading/error/ready contract.
+import { useQuery } from "@tanstack/react-query";
+
+import { toAPIQuery, type APIQuery } from "@/lib/api-query";
+
+import { listTenantRoles, type RoleOption } from "./agent-roles";
+
+function tenantRolesKey(tenantId: string | null) {
+ return ["tenant", tenantId ?? "none", "roles"] as const;
+}
+
+export function useTenantRoles(tenantId: string | null): APIQuery {
+ const result = useQuery({
+ queryKey: tenantRolesKey(tenantId),
+ enabled: tenantId !== null,
+ queryFn: () => listTenantRoles(tenantId as string),
+ });
+ return toAPIQuery(result);
+}
diff --git a/apps/web/src/agent-roles.test.ts b/apps/web/src/agent-roles.test.ts
new file mode 100644
index 000000000..34aba5adb
--- /dev/null
+++ b/apps/web/src/agent-roles.test.ts
@@ -0,0 +1,334 @@
+// Fail-closed role assignment for created agents, over a stubbed stock API:
+// names resolve against live roles, principals match run-anchored workflow
+// rows, and every refusal aborts with nothing half-applied.
+import { describe, expect, test } from "bun:test";
+
+import { UnauthenticatedError } from "./lib/api-query";
+import {
+ AgentRolesError,
+ assignAgentRoles,
+ assignRolesToAgent,
+ listTenantRoles,
+ resolveAgentPrincipalId,
+ resolveAgentRoleIds,
+} from "./agent-roles";
+
+type Route = {
+ readonly method: string;
+ readonly path: string;
+ readonly status: number;
+ readonly body?: unknown;
+};
+
+function stubFetch(routes: readonly Route[], calls: string[]): typeof fetch {
+ return (async (input: string | URL | Request, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = (init?.method ?? "GET").toUpperCase();
+ calls.push(`${method} ${url}`);
+ const route = routes.find((candidate) => candidate.method === method && candidate.path === url);
+ if (route === undefined) return new Response("no such route", { status: 404 });
+ const body = route.body === undefined ? "" : JSON.stringify(route.body);
+ return new Response(body, {
+ status: route.status,
+ headers: { "content-type": "application/json" },
+ });
+ }) as typeof fetch;
+}
+
+const TENANT = "tenant-1";
+
+function roleRow(id: string, name: string, isSystem = false) {
+ return {
+ id,
+ tenantId: TENANT,
+ name,
+ description: null,
+ isSystem,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ };
+}
+
+function rolesPage(names: readonly string[]) {
+ return {
+ data: names.map((name, index) => roleRow(`role-${index}`, name, name !== "custom")),
+ nextCursor: null,
+ };
+}
+
+function principalRow(id: string, refId: string, displayName: string, status = "active") {
+ return { id, kind: "workflow", refId, displayName, status };
+}
+
+function principalsPage(
+ rows: readonly ReturnType[],
+ nextCursor: string | null = null,
+) {
+ return { data: rows, nextCursor };
+}
+
+const PRINCIPALS_PATH = `/api/tenants/${TENANT}/principals?kind=workflow&limit=100`;
+const ROLES_PATH = `/api/tenants/${TENANT}/roles?limit=100`;
+
+describe("resolveAgentRoleIds", () => {
+ test("maps names to per-tenant ids in selection order, dropping repeats", async () => {
+ const calls: string[] = [];
+ const fetchImpl = stubFetch(
+ [
+ {
+ method: "GET",
+ path: ROLES_PATH,
+ status: 200,
+ body: rolesPage(["owner", "member", "custom"]),
+ },
+ ],
+ calls,
+ );
+ const ids = await resolveAgentRoleIds(TENANT, ["member", "owner", "member"], fetchImpl);
+ expect(ids).toEqual(["role-1", "role-0"]);
+ expect(calls).toEqual([`GET ${ROLES_PATH}`]);
+ });
+
+ test("an empty selection reads nothing", async () => {
+ const calls: string[] = [];
+ const ids = await resolveAgentRoleIds(TENANT, [], stubFetch([], calls));
+ expect(ids).toEqual([]);
+ expect(calls).toEqual([]);
+ });
+
+ test("an unknown name fails closed naming the role", async () => {
+ const calls: string[] = [];
+ const fetchImpl = stubFetch(
+ [{ method: "GET", path: ROLES_PATH, status: 200, body: rolesPage(["owner"]) }],
+ calls,
+ );
+ const error = await resolveAgentRoleIds(TENANT, ["owner", "auditor"], fetchImpl).catch(
+ (cause: unknown) => cause,
+ );
+ expect(error).toBeInstanceOf(AgentRolesError);
+ expect((error as Error).message).toContain('"auditor"');
+ });
+
+ test("a signed-out read surfaces as unauthenticated", async () => {
+ const fetchImpl = stubFetch([{ method: "GET", path: ROLES_PATH, status: 401 }], []);
+ const error = await listTenantRoles(TENANT, fetchImpl).catch((cause: unknown) => cause);
+ expect(error).toBeInstanceOf(UnauthenticatedError);
+ });
+});
+
+describe("resolveAgentPrincipalId", () => {
+ test("matches the anchor run principal by deployment id", async () => {
+ const fetchImpl = stubFetch(
+ [
+ {
+ method: "GET",
+ path: PRINCIPALS_PATH,
+ status: 200,
+ body: principalsPage([principalRow("principal-1", "deployment-9", "Workflow (old@x)")]),
+ },
+ ],
+ [],
+ );
+ const id = await resolveAgentPrincipalId(
+ TENANT,
+ { deploymentId: "deployment-9", address: "agent@t.localhost" },
+ fetchImpl,
+ );
+ expect(id).toBe("principal-1");
+ });
+
+ test("matches a live run principal by address display name", async () => {
+ const fetchImpl = stubFetch(
+ [
+ {
+ method: "GET",
+ path: PRINCIPALS_PATH,
+ status: 200,
+ body: principalsPage([
+ principalRow("principal-2", "run-7", "Workflow (agent@t.localhost)"),
+ ]),
+ },
+ ],
+ [],
+ );
+ const id = await resolveAgentPrincipalId(
+ TENANT,
+ { deploymentId: "deployment-9", address: "agent@t.localhost" },
+ fetchImpl,
+ );
+ expect(id).toBe("principal-2");
+ });
+
+ test("skips removed principals and returns null when none match", async () => {
+ const fetchImpl = stubFetch(
+ [
+ {
+ method: "GET",
+ path: PRINCIPALS_PATH,
+ status: 200,
+ body: principalsPage([
+ principalRow(
+ "principal-gone",
+ "deployment-9",
+ "Workflow (agent@t.localhost)",
+ "removed",
+ ),
+ principalRow("principal-other", "run-8", "Workflow (other@t.localhost)"),
+ ]),
+ },
+ ],
+ [],
+ );
+ const id = await resolveAgentPrincipalId(
+ TENANT,
+ { deploymentId: "deployment-9", address: "agent@t.localhost" },
+ fetchImpl,
+ );
+ expect(id).toBeNull();
+ });
+
+ test("follows cursor pages to the match", async () => {
+ const calls: string[] = [];
+ const fetchImpl = stubFetch(
+ [
+ {
+ method: "GET",
+ path: PRINCIPALS_PATH,
+ status: 200,
+ body: principalsPage(
+ [principalRow("principal-other", "run-8", "Workflow (x)")],
+ "cursor-1",
+ ),
+ },
+ {
+ method: "GET",
+ path: `${PRINCIPALS_PATH}&cursor=cursor-1`,
+ status: 200,
+ body: principalsPage([principalRow("principal-3", "deployment-9", "Workflow (y)")]),
+ },
+ ],
+ calls,
+ );
+ const id = await resolveAgentPrincipalId(TENANT, { deploymentId: "deployment-9" }, fetchImpl);
+ expect(id).toBe("principal-3");
+ expect(calls).toEqual([`GET ${PRINCIPALS_PATH}`, `GET ${PRINCIPALS_PATH}&cursor=cursor-1`]);
+ });
+});
+
+describe("assignAgentRoles", () => {
+ const assignPath = (principalId: string, roleId: string) =>
+ `/api/tenants/${TENANT}/principals/${principalId}/roles/${roleId}`;
+
+ test("posts each role in order", async () => {
+ const calls: string[] = [];
+ const fetchImpl = stubFetch(
+ [
+ { method: "POST", path: assignPath("principal-1", "role-0"), status: 204 },
+ { method: "POST", path: assignPath("principal-1", "role-1"), status: 204 },
+ ],
+ calls,
+ );
+ await assignAgentRoles(TENANT, "principal-1", ["role-0", "role-1"], fetchImpl);
+ expect(calls).toEqual([
+ `POST ${assignPath("principal-1", "role-0")}`,
+ `POST ${assignPath("principal-1", "role-1")}`,
+ ]);
+ });
+
+ test("a refused write stops the apply with the hub's message", async () => {
+ const calls: string[] = [];
+ const fetchImpl = stubFetch(
+ [
+ { method: "POST", path: assignPath("principal-1", "role-0"), status: 204 },
+ {
+ method: "POST",
+ path: assignPath("principal-1", "role-9"),
+ status: 404,
+ body: {
+ error: {
+ code: "not_found",
+ message: "Role not found",
+ userMessage: "That role is gone.",
+ },
+ },
+ },
+ { method: "POST", path: assignPath("principal-1", "role-1"), status: 204 },
+ ],
+ calls,
+ );
+ const error = await assignAgentRoles(
+ TENANT,
+ "principal-1",
+ ["role-0", "role-9", "role-1"],
+ fetchImpl,
+ ).catch((cause: unknown) => cause);
+ expect(error).toBeInstanceOf(AgentRolesError);
+ expect((error as Error).message).toContain("That role is gone.");
+ expect(calls).toEqual([
+ `POST ${assignPath("principal-1", "role-0")}`,
+ `POST ${assignPath("principal-1", "role-9")}`,
+ ]);
+ });
+});
+
+describe("assignRolesToAgent", () => {
+ test("an empty id list touches nothing", async () => {
+ const calls: string[] = [];
+ await assignRolesToAgent(
+ { tenantId: TENANT, roleIds: [], principalRef: { deploymentId: "deployment-9" } },
+ stubFetch([], calls),
+ );
+ expect(calls).toEqual([]);
+ });
+
+ test("a principal that does not exist yet fails with nothing assigned", async () => {
+ const calls: string[] = [];
+ const fetchImpl = stubFetch(
+ [{ method: "GET", path: PRINCIPALS_PATH, status: 200, body: principalsPage([]) }],
+ calls,
+ );
+ const error = await assignRolesToAgent(
+ {
+ tenantId: TENANT,
+ roleIds: ["role-0"],
+ principalRef: { deploymentId: "deployment-9", address: "agent@t.localhost" },
+ },
+ fetchImpl,
+ ).catch((cause: unknown) => cause);
+ expect(error).toBeInstanceOf(AgentRolesError);
+ expect((error as Error).message).toContain("hasn't run yet");
+ expect(calls).toEqual([`GET ${PRINCIPALS_PATH}`]);
+ });
+
+ test("resolves the principal, then assigns every id", async () => {
+ const calls: string[] = [];
+ const fetchImpl = stubFetch(
+ [
+ {
+ method: "GET",
+ path: PRINCIPALS_PATH,
+ status: 200,
+ body: principalsPage([principalRow("principal-1", "deployment-9", "Workflow (z)")]),
+ },
+ {
+ method: "POST",
+ path: `/api/tenants/${TENANT}/principals/principal-1/roles/role-0`,
+ status: 204,
+ },
+ ],
+ calls,
+ );
+ await assignRolesToAgent(
+ {
+ tenantId: TENANT,
+ roleIds: ["role-0"],
+ principalRef: { deploymentId: "deployment-9" },
+ },
+ fetchImpl,
+ );
+ expect(calls).toEqual([
+ `GET ${PRINCIPALS_PATH}`,
+ `POST /api/tenants/${TENANT}/principals/principal-1/roles/role-0`,
+ ]);
+ });
+});
diff --git a/apps/web/src/agent-roles.ts b/apps/web/src/agent-roles.ts
new file mode 100644
index 000000000..2e0e2558b
--- /dev/null
+++ b/apps/web/src/agent-roles.ts
@@ -0,0 +1,215 @@
+// Roles a created agent carries: the form lists the tenant's platform
+// roles, the person checks the ones the agent should hold, and the deploy
+// assigns them to the agent's workflow principal. Reads resolve against live
+// platform roles (never hardcoded ids); tenant bootstrap seeds the system
+// roles, so this module creates none. Every step fails closed: an unknown
+// name, a principal that does not exist yet, or a refused write aborts the
+// operation instead of leaving a half-roled agent behind.
+import { paginatedSchema, RoleResponse } from "@intx/types";
+import { type } from "arktype";
+import type { ArkErrors } from "arktype";
+
+import { UnauthenticatedError } from "@/lib/api-query";
+
+export class AgentRolesError extends Error {}
+
+export type RoleOption = {
+ readonly id: string;
+ readonly name: string;
+ readonly isSystem: boolean;
+};
+
+const RolesPage = paginatedSchema(RoleResponse);
+// The principal list's own loose shape, the same as threads-api's: a
+// removed principal still lists (so it is filtered, not validated away),
+// which the shared PrincipalResponse status enum would reject outright.
+const PrincipalsPage = type({
+ data: type({
+ id: "string",
+ kind: "string",
+ refId: "string",
+ displayName: "string",
+ status: "string",
+ }).array(),
+ nextCursor: "string | null",
+});
+
+type Validator = (data: unknown) => T | ArkErrors;
+
+function tenantPath(tenantId: string, suffix: string): string {
+ return `/api/tenants/${encodeURIComponent(tenantId)}${suffix}`;
+}
+
+async function readPage(response: Response, schema: Validator, what: string): Promise {
+ if (response.status === 401) {
+ throw new UnauthenticatedError();
+ }
+ if (!response.ok) {
+ throw new AgentRolesError(`${what} failed.`);
+ }
+ const parsed = schema(await response.json().catch(() => undefined));
+ if (parsed instanceof type.errors) {
+ throw new AgentRolesError(`${what} came back an unexpected shape.`);
+ }
+ return parsed;
+}
+
+async function fetchPage(
+ fetchImpl: typeof fetch,
+ path: string,
+ schema: Validator,
+ what: string,
+): Promise {
+ let response: Response;
+ try {
+ response = await fetchImpl(path, { headers: { accept: "application/json" } });
+ } catch (cause) {
+ throw new AgentRolesError(cause instanceof Error ? cause.message : `${what} failed.`);
+ }
+ return readPage(response, schema, what);
+}
+
+/** Lists the tenant's platform roles (system and custom), oldest first —
+ * tenant bootstrap seeds the system roles, so a fresh tenant lists
+ * owner/admin/member with nothing to create. */
+export async function listTenantRoles(
+ tenantId: string,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const roles: RoleOption[] = [];
+ let cursor: string | null = null;
+ for (;;) {
+ const path: string =
+ cursor === null
+ ? `${tenantPath(tenantId, "/roles")}?limit=100`
+ : `${tenantPath(tenantId, "/roles")}?limit=100&cursor=${encodeURIComponent(cursor)}`;
+ const page = await fetchPage(fetchImpl, path, RolesPage, "Listing this workbench's roles");
+ for (const role of page.data) {
+ roles.push({ id: role.id, name: role.name, isSystem: role.isSystem });
+ }
+ if (page.nextCursor === null) return roles;
+ cursor = page.nextCursor;
+ }
+}
+
+/** Resolves selected role names to the target tenant's role ids. Role ids
+ * are per-tenant, so callers pass names and resolve in the tenant the
+ * assignment lands in. Fails closed on the first unknown name, before any
+ * write, so a typo never half-applies a selection. */
+export async function resolveAgentRoleIds(
+ tenantId: string,
+ names: readonly string[],
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const wanted = [...new Set(names)];
+ if (wanted.length === 0) return [];
+ const roles = await listTenantRoles(tenantId, fetchImpl);
+ return wanted.map((name) => {
+ const found = roles.find((role) => role.name === name);
+ if (found === undefined) {
+ throw new AgentRolesError(`This workbench has no role named "${name}".`);
+ }
+ return found.id;
+ });
+}
+
+export type AgentPrincipalRef = {
+ /** The install's deployment id: a deployment's anchor run carries this id,
+ * so the run principal minted at first delivery matches it. */
+ readonly deploymentId: string;
+ /** The agent's run address (`slug@domain`), minted at deploy time: a
+ * workflow principal's display name renders as `Workflow ()`. */
+ readonly address?: string;
+};
+
+/** Finds the agent's workflow principal in the tenant: the anchor run
+ * principal by deployment id, else any live run principal under the agent's
+ * address. Returns null when the agent has never run — a deployment's
+ * workflow principal only appears after its first run, so a fresh agent has
+ * nothing to assign to yet. Removed principals never match. */
+export async function resolveAgentPrincipalId(
+ tenantId: string,
+ ref: AgentPrincipalRef,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const expectedName = ref.address === undefined ? null : `Workflow (${ref.address})`;
+ let cursor: string | null = null;
+ for (;;) {
+ const base = `${tenantPath(tenantId, "/principals")}?kind=workflow&limit=100`;
+ const path: string = cursor === null ? base : `${base}&cursor=${encodeURIComponent(cursor)}`;
+ const page = await fetchPage(
+ fetchImpl,
+ path,
+ PrincipalsPage,
+ "Listing this workbench's people",
+ );
+ for (const principal of page.data) {
+ if (principal.status === "removed") continue;
+ if (principal.refId === ref.deploymentId) return principal.id;
+ if (expectedName !== null && principal.displayName === expectedName) return principal.id;
+ }
+ if (page.nextCursor === null) return null;
+ cursor = page.nextCursor;
+ }
+}
+
+async function readAssignError(response: Response): Promise {
+ const body: unknown = await response.json().catch(() => undefined);
+ const envelope = type({
+ error: { code: "string", "message?": "string", "userMessage?": "string" },
+ })(body);
+ if (envelope instanceof type.errors) return `HTTP ${response.status}`;
+ return envelope.error.userMessage ?? envelope.error.message ?? `HTTP ${response.status}`;
+}
+
+/** Assigns each role to the principal, in order, stopping at the first
+ * refusal. Assignment is idempotent server-side, so retrying a partial
+ * apply converges instead of duplicating. */
+export async function assignAgentRoles(
+ tenantId: string,
+ principalId: string,
+ roleIds: readonly string[],
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ for (const roleId of roleIds) {
+ let response: Response;
+ try {
+ response = await fetchImpl(
+ `${tenantPath(tenantId, `/principals/${encodeURIComponent(principalId)}/roles/${encodeURIComponent(roleId)}`)}`,
+ { method: "POST" },
+ );
+ } catch (cause) {
+ throw new AgentRolesError(
+ cause instanceof Error ? cause.message : "Assigning this role failed.",
+ );
+ }
+ if (!response.ok) {
+ throw new AgentRolesError(`Assigning this role failed: ${await readAssignError(response)}`);
+ }
+ }
+}
+
+export type AssignRolesToAgentArgs = {
+ readonly tenantId: string;
+ /** Role ids already resolved against this tenant; empty is a no-op. */
+ readonly roleIds: readonly string[];
+ readonly principalRef: AgentPrincipalRef;
+};
+
+/** Assigns already-resolved role ids to the agent's workflow principal.
+ * Callers resolve names to ids first (failing fast on unknown names before
+ * anything deploys), so this runs after the install: a missing principal
+ * fails with nothing assigned. An empty id list is a no-op. */
+export async function assignRolesToAgent(
+ args: AssignRolesToAgentArgs,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ if (args.roleIds.length === 0) return;
+ const principalId = await resolveAgentPrincipalId(args.tenantId, args.principalRef, fetchImpl);
+ if (principalId === null) {
+ throw new AgentRolesError(
+ "This agent hasn't run yet, so its roles can't be assigned. Send it a message, then try again and the roles will apply.",
+ );
+ }
+ await assignAgentRoles(args.tenantId, principalId, args.roleIds, fetchImpl);
+}
diff --git a/apps/web/src/pages/create-agent-panel.tsx b/apps/web/src/pages/create-agent-panel.tsx
index f506da1d8..7a92661be 100644
--- a/apps/web/src/pages/create-agent-panel.tsx
+++ b/apps/web/src/pages/create-agent-panel.tsx
@@ -17,6 +17,7 @@ import { useState } from "react";
import { ApiQueryError } from "@/lib/api-query";
import type { DeployedAgent } from "../agent-deploy";
+import { useTenantRoles } from "../agent-roles-query";
import { useDeployAgentMutation } from "../agents-api";
import { useMcpServers } from "../tools/mcp-servers-query";
import "./create-agent-panel.css";
@@ -63,8 +64,13 @@ export function CreateAgentPanel({
// Handles checked below, out of the workspace catalog — each becomes a
// definition binding plus a use requirement, the same as Worker's Exa.
const [selectedHandles, setSelectedHandles] = useState([]);
+ // Role names checked below, out of this workbench's roles — each is
+ // resolved to the workbench's role id and assigned to the agent once it
+ // deploys.
+ const [selectedRoles, setSelectedRoles] = useState([]);
const deploy = useDeployAgentMutation(tenantId);
const catalog = useMcpServers(tenantId);
+ const roles = useTenantRoles(tenantId);
function toggleHandle(handle: string) {
setSelectedHandles((current) =>
@@ -72,10 +78,17 @@ export function CreateAgentPanel({
);
}
+ function toggleRole(name: string) {
+ setSelectedRoles((current) =>
+ current.includes(name) ? current.filter((role) => role !== name) : [...current, name],
+ );
+ }
+
function reset() {
setValues(EMPTY_VALUES);
setSystemPrompt("");
setSelectedHandles([]);
+ setSelectedRoles([]);
deploy.reset();
}
@@ -89,7 +102,13 @@ export function CreateAgentPanel({
? "Add a name to continue."
: systemPrompt.trim() === ""
? "Write a system prompt to continue."
- : null;
+ : roles.kind === "loading"
+ ? "Roles are still loading."
+ : roles.kind === "error"
+ ? "Roles failed to load."
+ : roles.kind === "unauthenticated"
+ ? "Sign in again to deploy."
+ : null;
function handleSubmit() {
if (blocked !== null) return;
@@ -98,6 +117,7 @@ export function CreateAgentPanel({
name: values.name.trim(),
systemPrompt: systemPrompt.trim(),
...(selectedHandles.length > 0 ? { mcpHandles: selectedHandles } : {}),
+ ...(selectedRoles.length > 0 ? { roles: selectedRoles } : {}),
},
{
onSuccess: (deployment) => {
@@ -196,6 +216,44 @@ export function CreateAgentPanel({
)}
+