Add CEO OpenClaw invite endpoint and update onboarding UX
This commit is contained in:
@@ -18,20 +18,28 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
|
||||
|
||||
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
|
||||
|
||||
4. Use the agent snippet flow.
|
||||
- Copy the snippet from company settings.
|
||||
4. Use the OpenClaw invite prompt flow.
|
||||
- In the Invites section, click `Generate OpenClaw Invite Prompt`.
|
||||
- Copy the generated prompt from `OpenClaw Invite Prompt`.
|
||||
- Paste it into OpenClaw main chat as one message.
|
||||
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
|
||||
|
||||
Security/control note:
|
||||
- The OpenClaw invite prompt is created from a controlled endpoint:
|
||||
- `POST /api/companies/{companyId}/openclaw/invite-prompt`
|
||||
- board users with invite permission can call it
|
||||
- agent callers are limited to the company CEO agent
|
||||
|
||||
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
|
||||
|
||||
6. Gateway preflight (required before task tests).
|
||||
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
|
||||
- Confirm gateway URL is `ws://...` or `wss://...`.
|
||||
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
|
||||
- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding.
|
||||
- Confirm pairing mode is explicit:
|
||||
- recommended default: `adapterConfig.disableDeviceAuth` is false/absent and `adapterConfig.devicePrivateKeyPem` is present
|
||||
- fallback only: `adapterConfig.disableDeviceAuth=true` when pairing cannot be supported in that environment
|
||||
- required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem`
|
||||
- do not rely on `disableDeviceAuth` for normal onboarding
|
||||
- If you can run API checks with board auth:
|
||||
```bash
|
||||
AGENT_ID="<newly-created-agent-id>"
|
||||
@@ -40,8 +48,9 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT
|
||||
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
||||
|
||||
Pairing handshake note:
|
||||
- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
|
||||
- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key.
|
||||
- Clean run expectation: first task should succeed without manual pairing commands.
|
||||
- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
|
||||
- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`.
|
||||
- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself.
|
||||
- Approve it in OpenClaw, then retry the task.
|
||||
- For local docker smoke, you can approve from host:
|
||||
|
||||
@@ -197,6 +197,7 @@ export {
|
||||
updateBudgetSchema,
|
||||
createAssetImageMetadataSchema,
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
@@ -206,6 +207,7 @@ export {
|
||||
type UpdateBudget,
|
||||
type CreateAssetImageMetadata,
|
||||
type CreateCompanyInvite,
|
||||
type CreateOpenClawInvitePrompt,
|
||||
type AcceptInvite,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
|
||||
@@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({
|
||||
|
||||
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
|
||||
|
||||
export const createOpenClawInvitePromptSchema = z.object({
|
||||
agentMessage: z.string().max(4000).optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateOpenClawInvitePrompt = z.infer<
|
||||
typeof createOpenClawInvitePromptSchema
|
||||
>;
|
||||
|
||||
export const acceptInviteSchema = z.object({
|
||||
requestType: z.enum(JOIN_REQUEST_TYPES),
|
||||
agentName: z.string().min(1).max(120).optional(),
|
||||
|
||||
@@ -119,12 +119,14 @@ export {
|
||||
|
||||
export {
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
acceptInviteSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCompanyInvite,
|
||||
type CreateOpenClawInvitePrompt,
|
||||
type AcceptInvite,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
|
||||
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
181
server/src/__tests__/openclaw-invite-prompt-route.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accessRoutes } from "../routes/access.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
isInstanceAdmin: vi.fn(),
|
||||
getMembership: vi.fn(),
|
||||
ensureMembership: vi.fn(),
|
||||
listMembers: vi.fn(),
|
||||
setMemberPermissions: vi.fn(),
|
||||
promoteInstanceAdmin: vi.fn(),
|
||||
demoteInstanceAdmin: vi.fn(),
|
||||
listUserCompanyAccess: vi.fn(),
|
||||
setUserCompanyAccess: vi.fn(),
|
||||
setPrincipalGrants: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
}));
|
||||
|
||||
function createDbStub() {
|
||||
const createdInvite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
|
||||
invitedByUserId: null,
|
||||
tokenHash: "hash",
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const returning = vi.fn().mockResolvedValue([createdInvite]);
|
||||
const values = vi.fn().mockReturnValue({ returning });
|
||||
const insert = vi.fn().mockReturnValue({ values });
|
||||
return {
|
||||
insert,
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes(db as any, {
|
||||
deploymentMode: "local_trusted",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
|
||||
beforeEach(() => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAgentService.getById.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("rejects non-CEO agent callers", async () => {
|
||||
const db = createDbStub();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("Only CEO agents");
|
||||
});
|
||||
|
||||
it("allows CEO agent callers and creates an agent-only invite", async () => {
|
||||
const db = createDbStub();
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "ceo",
|
||||
});
|
||||
const app = createApp(
|
||||
{
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
source: "agent_key",
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({ agentMessage: "Join and configure OpenClaw gateway." });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
expect(typeof res.body.token).toBe("string");
|
||||
expect(res.body.onboardingTextPath).toContain("/api/invites/");
|
||||
});
|
||||
|
||||
it("allows board callers with invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.allowedJoinTypes).toBe("agent");
|
||||
});
|
||||
|
||||
it("rejects board callers without invite permission", async () => {
|
||||
const db = createDbStub();
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
const app = createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/openclaw/invite-prompt")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Permission denied");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
acceptInviteSchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
@@ -1942,6 +1943,80 @@ export function accessRoutes(
|
||||
if (!allowed) throw forbidden("Permission denied");
|
||||
}
|
||||
|
||||
async function assertCanGenerateOpenClawInvitePrompt(
|
||||
req: Request,
|
||||
companyId: string
|
||||
) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "agent") {
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
const actorAgent = await agents.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
if (actorAgent.role !== "ceo") {
|
||||
throw forbidden("Only CEO agents can generate OpenClaw invite prompts");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (req.actor.type !== "board") throw unauthorized();
|
||||
if (isLocalImplicit(req)) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "users:invite");
|
||||
if (!allowed) throw forbidden("Permission denied");
|
||||
}
|
||||
|
||||
async function createCompanyInviteForCompany(input: {
|
||||
req: Request;
|
||||
companyId: string;
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
defaultsPayload?: Record<string, unknown> | null;
|
||||
agentMessage?: string | null;
|
||||
}) {
|
||||
const normalizedAgentMessage =
|
||||
typeof input.agentMessage === "string"
|
||||
? input.agentMessage.trim() || null
|
||||
: null;
|
||||
const insertValues = {
|
||||
companyId: input.companyId,
|
||||
inviteType: "company_join" as const,
|
||||
allowedJoinTypes: input.allowedJoinTypes,
|
||||
defaultsPayload: mergeInviteDefaults(
|
||||
input.defaultsPayload ?? null,
|
||||
normalizedAgentMessage
|
||||
),
|
||||
expiresAt: companyInviteExpiresAt(),
|
||||
invitedByUserId: input.req.actor.userId ?? null
|
||||
};
|
||||
|
||||
let token: string | null = null;
|
||||
let created: typeof invites.$inferSelect | null = null;
|
||||
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
|
||||
const candidateToken = createInviteToken();
|
||||
try {
|
||||
const row = await db
|
||||
.insert(invites)
|
||||
.values({
|
||||
...insertValues,
|
||||
tokenHash: hashToken(candidateToken)
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
token = candidateToken;
|
||||
created = row;
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!isInviteTokenHashCollisionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!token || !created) {
|
||||
throw conflict("Failed to generate a unique invite token. Please retry.");
|
||||
}
|
||||
|
||||
return { token, created, normalizedAgentMessage };
|
||||
}
|
||||
|
||||
router.get("/skills/index", (_req, res) => {
|
||||
res.json({
|
||||
skills: [
|
||||
@@ -1967,49 +2042,14 @@ export function accessRoutes(
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCompanyPermission(req, companyId, "users:invite");
|
||||
const normalizedAgentMessage =
|
||||
typeof req.body.agentMessage === "string"
|
||||
? req.body.agentMessage.trim() || null
|
||||
: null;
|
||||
const insertValues = {
|
||||
companyId,
|
||||
inviteType: "company_join" as const,
|
||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||
defaultsPayload: mergeInviteDefaults(
|
||||
req.body.defaultsPayload ?? null,
|
||||
normalizedAgentMessage
|
||||
),
|
||||
expiresAt: companyInviteExpiresAt(),
|
||||
invitedByUserId: req.actor.userId ?? null
|
||||
};
|
||||
|
||||
let token: string | null = null;
|
||||
let created: typeof invites.$inferSelect | null = null;
|
||||
for (let attempt = 0; attempt < INVITE_TOKEN_MAX_RETRIES; attempt += 1) {
|
||||
const candidateToken = createInviteToken();
|
||||
try {
|
||||
const row = await db
|
||||
.insert(invites)
|
||||
.values({
|
||||
...insertValues,
|
||||
tokenHash: hashToken(candidateToken)
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
token = candidateToken;
|
||||
created = row;
|
||||
break;
|
||||
} catch (error) {
|
||||
if (!isInviteTokenHashCollisionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!token || !created) {
|
||||
throw conflict(
|
||||
"Failed to generate a unique invite token. Please retry."
|
||||
);
|
||||
}
|
||||
const { token, created, normalizedAgentMessage } =
|
||||
await createCompanyInviteForCompany({
|
||||
req,
|
||||
companyId,
|
||||
allowedJoinTypes: req.body.allowedJoinTypes,
|
||||
defaultsPayload: req.body.defaultsPayload ?? null,
|
||||
agentMessage: req.body.agentMessage ?? null
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
@@ -2041,6 +2081,51 @@ export function accessRoutes(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/openclaw/invite-prompt",
|
||||
validate(createOpenClawInvitePromptSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanGenerateOpenClawInvitePrompt(req, companyId);
|
||||
const { token, created, normalizedAgentMessage } =
|
||||
await createCompanyInviteForCompany({
|
||||
req,
|
||||
companyId,
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: null,
|
||||
agentMessage: req.body.agentMessage ?? null
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
actorId:
|
||||
req.actor.type === "agent"
|
||||
? req.actor.agentId ?? "unknown-agent"
|
||||
: req.actor.userId ?? "board",
|
||||
action: "invite.openclaw_prompt_created",
|
||||
entityType: "invite",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
inviteType: created.inviteType,
|
||||
allowedJoinTypes: created.allowedJoinTypes,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
hasAgentMessage: Boolean(normalizedAgentMessage)
|
||||
}
|
||||
});
|
||||
|
||||
const inviteSummary = toInviteSummaryResponse(req, token, created);
|
||||
res.status(201).json({
|
||||
...created,
|
||||
token,
|
||||
inviteUrl: `/invite/${token}`,
|
||||
onboardingTextPath: inviteSummary.onboardingTextPath,
|
||||
onboardingTextUrl: inviteSummary.onboardingTextUrl,
|
||||
inviteMessage: inviteSummary.inviteMessage
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
router.get("/invites/:token", async (req, res) => {
|
||||
const token = (req.params.token as string).trim();
|
||||
if (!token) throw notFound("Invite not found");
|
||||
|
||||
@@ -91,6 +91,30 @@ Workspace rules:
|
||||
- For repo-only setup, omit `cwd` and provide `repoUrl`.
|
||||
- Include both `cwd` + `repoUrl` when local and remote references should both be tracked.
|
||||
|
||||
## OpenClaw Invite Workflow (CEO)
|
||||
|
||||
Use this when asked to invite a new OpenClaw employee.
|
||||
|
||||
1. Generate a fresh OpenClaw invite prompt:
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/openclaw/invite-prompt
|
||||
{ "agentMessage": "optional onboarding note for OpenClaw" }
|
||||
```
|
||||
|
||||
Access control:
|
||||
- Board users with invite permission can call it.
|
||||
- Agent callers: only the company CEO agent can call it.
|
||||
|
||||
2. Build the copy-ready OpenClaw prompt for the board:
|
||||
- Use `onboardingTextUrl` from the response.
|
||||
- Ask the board to paste that prompt into OpenClaw.
|
||||
- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`.
|
||||
|
||||
3. Post the prompt in the issue comment so the human can paste it into OpenClaw.
|
||||
|
||||
4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install).
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **Always checkout** before working. Never PATCH to `in_progress` manually.
|
||||
@@ -206,6 +230,7 @@ PATCH /api/agents/{agentId}/instructions-path
|
||||
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
|
||||
| Add comment | `POST /api/issues/:issueId/comments` |
|
||||
| Create subtask | `POST /api/companies/:companyId/issues` |
|
||||
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
|
||||
| Create project | `POST /api/companies/:companyId/projects` |
|
||||
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
|
||||
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |
|
||||
|
||||
@@ -280,6 +280,23 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts,
|
||||
|
||||
Use the dashboard for situational awareness, especially if you're a manager or CEO.
|
||||
|
||||
## OpenClaw Invite Prompt (CEO)
|
||||
|
||||
Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt:
|
||||
|
||||
```
|
||||
POST /api/companies/{companyId}/openclaw/invite-prompt
|
||||
{
|
||||
"agentMessage": "optional note for the joining OpenClaw agent"
|
||||
}
|
||||
```
|
||||
|
||||
Response includes invite token, onboarding text URL, and expiry metadata.
|
||||
|
||||
Access is intentionally constrained:
|
||||
- board users with invite permission
|
||||
- CEO agent only (non-CEO agents are rejected)
|
||||
|
||||
---
|
||||
|
||||
## Setting Agent Instructions Path
|
||||
@@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled`
|
||||
| GET | `/api/goals/:goalId` | Goal details |
|
||||
| POST | `/api/companies/:companyId/goals` | Create goal |
|
||||
| PATCH | `/api/goals/:goalId` | Update goal |
|
||||
| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) |
|
||||
|
||||
### Approvals, Costs, Activity, Dashboard
|
||||
|
||||
|
||||
@@ -64,6 +64,17 @@ type BoardClaimStatus = {
|
||||
claimedByUserId: string | null;
|
||||
};
|
||||
|
||||
type CompanyInviteCreated = {
|
||||
id: string;
|
||||
token: string;
|
||||
inviteUrl: string;
|
||||
expiresAt: string;
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
};
|
||||
|
||||
export const accessApi = {
|
||||
createCompanyInvite: (
|
||||
companyId: string,
|
||||
@@ -73,16 +84,18 @@ export const accessApi = {
|
||||
agentMessage?: string | null;
|
||||
} = {},
|
||||
) =>
|
||||
api.post<{
|
||||
id: string;
|
||||
token: string;
|
||||
inviteUrl: string;
|
||||
expiresAt: string;
|
||||
allowedJoinTypes: "human" | "agent" | "both";
|
||||
onboardingTextPath?: string;
|
||||
onboardingTextUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
}>(`/companies/${companyId}/invites`, input),
|
||||
api.post<CompanyInviteCreated>(`/companies/${companyId}/invites`, input),
|
||||
|
||||
createOpenClawInvitePrompt: (
|
||||
companyId: string,
|
||||
input: {
|
||||
agentMessage?: string | null;
|
||||
} = {},
|
||||
) =>
|
||||
api.post<CompanyInviteCreated>(
|
||||
`/companies/${companyId}/openclaw/invite-prompt`,
|
||||
input,
|
||||
),
|
||||
|
||||
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
|
||||
getInviteOnboarding: (token: string) =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, type ComponentType } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
@@ -9,12 +10,77 @@ import {
|
||||
DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, Sparkles } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Code,
|
||||
MousePointer2,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
|
||||
type AdvancedAdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
| "opencode_local"
|
||||
| "pi_local"
|
||||
| "cursor"
|
||||
| "openclaw_gateway";
|
||||
|
||||
const ADVANCED_ADAPTER_OPTIONS: Array<{
|
||||
value: AdvancedAdapterType;
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
recommended?: boolean;
|
||||
}> = [
|
||||
{
|
||||
value: "claude_local",
|
||||
label: "Claude Code",
|
||||
icon: Sparkles,
|
||||
desc: "Local Claude agent",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: "codex_local",
|
||||
label: "Codex",
|
||||
icon: Code,
|
||||
desc: "Local Codex agent",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
value: "opencode_local",
|
||||
label: "OpenCode",
|
||||
icon: OpenCodeLogoIcon,
|
||||
desc: "Local multi-provider agent",
|
||||
},
|
||||
{
|
||||
value: "pi_local",
|
||||
label: "Pi",
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent",
|
||||
},
|
||||
{
|
||||
value: "cursor",
|
||||
label: "Cursor",
|
||||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent",
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway",
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol",
|
||||
},
|
||||
];
|
||||
|
||||
export function NewAgentDialog() {
|
||||
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const navigate = useNavigate();
|
||||
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -34,15 +100,23 @@ export function NewAgentDialog() {
|
||||
}
|
||||
|
||||
function handleAdvancedConfig() {
|
||||
setShowAdvancedCards(true);
|
||||
}
|
||||
|
||||
function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) {
|
||||
closeNewAgent();
|
||||
navigate("/agents/new");
|
||||
setShowAdvancedCards(false);
|
||||
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={newAgentOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeNewAgent();
|
||||
if (!open) {
|
||||
setShowAdvancedCards(false);
|
||||
closeNewAgent();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
@@ -56,39 +130,84 @@ export function NewAgentDialog() {
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={closeNewAgent}
|
||||
onClick={() => {
|
||||
setShowAdvancedCards(false);
|
||||
closeNewAgent();
|
||||
}}
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Recommendation */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||
<Sparkles className="h-6 w-6 text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We recommend letting your CEO handle agent setup — they know the
|
||||
org structure and can configure reporting, permissions, and
|
||||
adapters.
|
||||
</p>
|
||||
</div>
|
||||
{!showAdvancedCards ? (
|
||||
<>
|
||||
{/* Recommendation */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||
<Sparkles className="h-6 w-6 text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We recommend letting your CEO handle agent setup — they know the
|
||||
org structure and can configure reporting, permissions, and
|
||||
adapters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" size="lg" onClick={handleAskCeo}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
Ask the CEO to create a new agent
|
||||
</Button>
|
||||
<Button className="w-full" size="lg" onClick={handleAskCeo}>
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
Ask the CEO to create a new agent
|
||||
</Button>
|
||||
|
||||
{/* Advanced link */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||
onClick={handleAdvancedConfig}
|
||||
>
|
||||
I want advanced configuration myself
|
||||
</button>
|
||||
</div>
|
||||
{/* Advanced link */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||
onClick={handleAdvancedConfig}
|
||||
>
|
||||
I want advanced configuration myself
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowAdvancedCards(false)}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back
|
||||
</button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose your adapter type for advanced setup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ADVANCED_ADAPTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative"
|
||||
)}
|
||||
onClick={() => handleAdvancedAdapterPick(opt.value)}
|
||||
>
|
||||
{opt.recommended && (
|
||||
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Terminal,
|
||||
Globe,
|
||||
Sparkles,
|
||||
MousePointer2,
|
||||
Check,
|
||||
@@ -673,38 +672,19 @@ export function OnboardingWizard() {
|
||||
icon: Terminal,
|
||||
desc: "Local Pi agent"
|
||||
},
|
||||
{
|
||||
value: "openclaw" as const,
|
||||
label: "OpenClaw",
|
||||
icon: Bot,
|
||||
desc: "Notify OpenClaw webhook",
|
||||
comingSoon: true
|
||||
},
|
||||
{
|
||||
value: "openclaw_gateway" as const,
|
||||
label: "OpenClaw Gateway",
|
||||
icon: Bot,
|
||||
desc: "Invoke OpenClaw via gateway protocol"
|
||||
desc: "Invoke OpenClaw via gateway protocol",
|
||||
comingSoon: true,
|
||||
disabledLabel: "Configure OpenClaw within the App"
|
||||
},
|
||||
{
|
||||
value: "cursor" as const,
|
||||
label: "Cursor",
|
||||
icon: MousePointer2,
|
||||
desc: "Local Cursor agent"
|
||||
},
|
||||
{
|
||||
value: "process" as const,
|
||||
label: "Shell Command",
|
||||
icon: Terminal,
|
||||
desc: "Run a process",
|
||||
comingSoon: true
|
||||
},
|
||||
{
|
||||
value: "http" as const,
|
||||
label: "HTTP Webhook",
|
||||
icon: Globe,
|
||||
desc: "Call an endpoint",
|
||||
comingSoon: true
|
||||
}
|
||||
].map((opt) => (
|
||||
<button
|
||||
@@ -744,7 +724,10 @@ export function OnboardingWizard() {
|
||||
<opt.icon className="h-4 w-4" />
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{opt.comingSoon ? "Coming soon" : opt.desc}
|
||||
{opt.comingSoon
|
||||
? (opt as { disabledLabel?: string }).disabledLabel ??
|
||||
"Coming soon"
|
||||
: opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -77,9 +77,7 @@ export function CompanySettings() {
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
accessApi.createCompanyInvite(selectedCompanyId!, {
|
||||
allowedJoinTypes: "agent"
|
||||
}),
|
||||
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
||||
onSuccess: async (invite) => {
|
||||
setInviteError(null);
|
||||
const base = window.location.origin.replace(/\/+$/, "");
|
||||
@@ -317,9 +315,9 @@ export function CompanySettings() {
|
||||
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Generate an agent snippet for join flows.
|
||||
Generate an openclaw agent invite snippet.
|
||||
</span>
|
||||
<HintIcon text="Creates an agent-only invite (10m) and renders a copy-ready snippet." />
|
||||
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
@@ -329,7 +327,7 @@ export function CompanySettings() {
|
||||
>
|
||||
{inviteMutation.isPending
|
||||
? "Generating..."
|
||||
: "Generate agent snippet"}
|
||||
: "Generate OpenClaw Invite Prompt"}
|
||||
</Button>
|
||||
</div>
|
||||
{inviteError && (
|
||||
@@ -339,7 +337,7 @@ export function CompanySettings() {
|
||||
<div className="rounded-md border border-border bg-muted/30 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Agent Snippet
|
||||
OpenClaw Invite Prompt
|
||||
</div>
|
||||
{snippetCopied && (
|
||||
<span
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
@@ -19,12 +19,45 @@ import { AgentConfigForm, type CreateConfigValues } from "../components/AgentCon
|
||||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
|
||||
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
"openclaw_gateway",
|
||||
]);
|
||||
|
||||
function createValuesForAdapterType(
|
||||
adapterType: CreateConfigValues["adapterType"],
|
||||
): CreateConfigValues {
|
||||
const { adapterType: _discard, ...defaults } = defaultCreateValues;
|
||||
const nextValues: CreateConfigValues = { ...defaults, adapterType };
|
||||
if (adapterType === "codex_local") {
|
||||
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
|
||||
nextValues.dangerouslyBypassSandbox =
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
} else if (adapterType === "cursor") {
|
||||
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
} else if (adapterType === "opencode_local") {
|
||||
nextValues.model = "";
|
||||
}
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
export function NewAgent() {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const presetAdapterType = searchParams.get("adapterType");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -71,6 +104,18 @@ export function NewAgent() {
|
||||
}
|
||||
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const requested = presetAdapterType;
|
||||
if (!requested) return;
|
||||
if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) {
|
||||
return;
|
||||
}
|
||||
setConfigValues((prev) => {
|
||||
if (prev.adapterType === requested) return prev;
|
||||
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);
|
||||
});
|
||||
}, [presetAdapterType]);
|
||||
|
||||
const createAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
agentsApi.hire(selectedCompanyId!, data),
|
||||
|
||||
Reference in New Issue
Block a user