Skip to content

Commit 13cfebe

Browse files
author
Ricardo DeMatos
committed
feat(managed-agent): Claude Platform Managed Agents workflow blocks
Ship two workflow blocks that let a workflow author invoke a Claude Platform Managed Agent (cloud or self-hosted) as a first-class node, plus the workspace-scoped connection layer that stores the linked Anthropic API key. Blocks - **Claude Managed Agents** (`managed_agent_cloud`) — cloud environments. Session-create payload carries `agent`, `environment_id`, `vault_ids`, `resources` (memory + files) and free-form `metadata` tags. - **Claude Managed Agents (self-hosted)** (`managed_agent_self_hosted`) — self-hosted environments. Session `metadata` is forwarded as env vars to the deployer's self-hosted agent sandbox; the memory fields are gated behind `NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_MEMORY_ENABLED` because Claude self-hosted environments do not currently support the memory-store resource attach on the session API. Default seed rows for the Session-parameters table come from `NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS` (JSON object); the seeded keys stay out of source so this block is deployment- neutral in the public tree. Connections - New `managed_agent_connection` table (workspace-scoped, encrypted API key). All CRUD paths go through `getUserEntityPermissions(userId, 'workspace', workspaceId)`; `admin`/`write` to create, rotate, or delete; any workspace member can list/browse. Plaintext keys never cross the client boundary — list responses return only a masked preview. - Settings surface under `settings/components/managed-agents/` lets admins add / rotate / remove connections with an inline Anthropic-API verify (`GET /v1/agents`). - 8 server routes proxy the Claude Platform read endpoints (agents, environments, memory stores, vaults, environment detail) so the block picker resolves options against the linked workspace. Tool + session client - `tools/managed_agent/run_session.ts` (client-safe skeleton) + `run_session.server.ts` (server-only impl, self-registered on boot via `globalThis` so the client bundle never pulls `postgres` / `fs` / encryption). Reconnect + `events.list` catch-up loop matches the docs' two-step session lifecycle. - `lib/managed-agents/session-client.ts` — the reusable HTTP client that shapes the cloud vs. self-hosted request bodies. - `tools/managed_agent/normalizers.ts` — pure input normalizers that coerce the workflow block's runtime shapes (table rows, JSON strings, flat objects, comma-lists) into typed values. Node UX - Optional `BlockConfig.nodeWidth` (default 250, MA blocks use 400) threaded through `calculateWorkflowBlockDimensions` for both the workflow-block hook and autolayout so long Anthropic IDs are not ellipsis-truncated in the collapsed row. - Friendly-name hydration on the collapsed row resolves connection / agent / environment / vault / memory-store IDs to human labels via the React Query hooks. New `blockType` prop on `SubBlockRow` gates the lookup. - New optional `defaultRows` prop on the `Table` subblock so any block can seed a fresh table with initial rows. Test coverage Aligned with the repo's existing patterns (`@vitest-environment node`, `vi.hoisted` + `vi.mock` + static imports, `@sim/testing` helpers). 86 tests across 5 files: - `tools/managed_agent/normalizers.test.ts` (27) — every shape the block subblocks hand off + bad-input paths. - `lib/managed-agents/session-client.test.ts` (17) — cloud vs. self-hosted branch: memory routing (resource vs. metadata), file resources cloud-only, `vault_ids` only when non-empty, user metadata does not overwrite memory keys, envType-omitted default. - `lib/managed-agents/connections.test.ts` (17) — CRUD via captured DB-chain args: encryption on write (plaintext never lands in row), masking on read, workspace-scoped `WHERE` on every op, `verify` gate blocks persistence, error truncation to 500 chars. - `app/api/managed-agent-connections/route.test.ts` (15) — permission gates on GET/POST/DELETE: 401 unauthenticated, 400 missing params, 403 read-tier callers on create/delete, 404 for missing rows, plaintext never in POST response, both `write` and `admin` accepted. - `blocks/blocks/managed_agent_self_hosted.test.ts` (10) — env-driven `readSessionMetadataDefaults` and `isSelfHostedMemoryEnabled` (truthy/falsy/whitespace forms). Documentation - New env vars documented in `apps/sim/.env.example`: `MANAGED_AGENT_DEBUG_PAYLOAD`, `NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS`, `NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_MEMORY_ENABLED`.
1 parent 4ddad74 commit 13cfebe

49 files changed

Lines changed: 22435 additions & 12 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
6464
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
6565
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
6666
# NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI.
67+
# NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_DEFAULTS='{"SOURCE_TYPE":"git","SOURCE_REF":"main"}' # JSON object of default session-metadata rows seeded into fresh Claude Managed Agents (self-hosted) blocks. Deployment-specific; keys map to whatever env vars your self-hosted agent sandbox reads. Absent = empty table.
68+
# NEXT_PUBLIC_MANAGED_AGENT_SELF_HOSTED_MEMORY_ENABLED=true # Reveal Memory Store + Memory Access fields on the Claude Managed Agents (self-hosted) block. Off by default: Claude self-hosted environments do not currently support the memory-store resource attach on the session API. Enable only if your self-hosted agent sandbox implements memory attach on its side.
6769
# AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED)
6870
# AZURE_OPENAI_API_KEY= # Azure OpenAI API key
6971
# AZURE_OPENAI_API_VERSION= # Azure OpenAI API version
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import {
5+
type ManagedAgentAgent,
6+
managedAgentProxyParamsSchema,
7+
managedAgentProxyQuerySchema,
8+
} from '@/lib/api/contracts'
9+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
10+
import { generateRequestId } from '@/lib/core/utils/request'
11+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { getDecryptedApiKey } from '@/lib/managed-agents/connections'
13+
import {
14+
type AnthropicListPage,
15+
ManagedAgentProxyError,
16+
proxyManagedAgentsGet,
17+
} from '@/lib/managed-agents/proxy'
18+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
19+
20+
const logger = createLogger('ManagedAgentAgentsAPI')
21+
22+
interface AnthropicAgent {
23+
id: string
24+
name?: string
25+
description?: string
26+
latest_version?: number | string
27+
version?: number | string
28+
}
29+
30+
interface RouteContext {
31+
params: Promise<{ id: string }>
32+
}
33+
34+
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
35+
const requestId = generateRequestId()
36+
try {
37+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
38+
if (!authResult.success || !authResult.userId) {
39+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
40+
}
41+
const userId = authResult.userId
42+
43+
const rawParams = await context.params
44+
const paramsValidation = managedAgentProxyParamsSchema.safeParse(rawParams)
45+
const queryValidation = managedAgentProxyQuerySchema.safeParse(
46+
Object.fromEntries(request.nextUrl.searchParams.entries())
47+
)
48+
if (!paramsValidation.success || !queryValidation.success) {
49+
return NextResponse.json({ error: 'Invalid request data' }, { status: 400 })
50+
}
51+
const { id } = paramsValidation.data
52+
const { workspaceId } = queryValidation.data
53+
54+
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
55+
if (!userPermission) {
56+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
57+
}
58+
59+
const apiKey = await getDecryptedApiKey({ id, workspaceId })
60+
if (!apiKey) {
61+
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
62+
}
63+
64+
try {
65+
const body = await proxyManagedAgentsGet<AnthropicListPage<AnthropicAgent>>(
66+
apiKey,
67+
'/v1/agents?limit=100'
68+
)
69+
const data: ManagedAgentAgent[] = (body.data ?? []).map((row) => ({
70+
id: row.id,
71+
name: row.name ?? null,
72+
description: row.description ?? null,
73+
version: row.latest_version ?? row.version ?? null,
74+
}))
75+
return NextResponse.json({ data })
76+
} catch (error) {
77+
if (error instanceof ManagedAgentProxyError) {
78+
return NextResponse.json({ error: error.message }, { status: 502 })
79+
}
80+
throw error
81+
}
82+
} catch (error) {
83+
logger.error(`[${requestId}] Failed to list agents`, error)
84+
return NextResponse.json(
85+
{ error: getErrorMessage(error, 'Failed to list agents') },
86+
{ status: 500 }
87+
)
88+
}
89+
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import {
5+
type ManagedAgentEnvironment,
6+
managedAgentEnvironmentDetailParamsSchema,
7+
managedAgentProxyQuerySchema,
8+
} from '@/lib/api/contracts'
9+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
10+
import { generateRequestId } from '@/lib/core/utils/request'
11+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { getDecryptedApiKey } from '@/lib/managed-agents/connections'
13+
import {
14+
ManagedAgentProxyError,
15+
proxyManagedAgentsGet,
16+
} from '@/lib/managed-agents/proxy'
17+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
18+
19+
const logger = createLogger('ManagedAgentEnvironmentAPI')
20+
21+
interface AnthropicEnvironment {
22+
id: string
23+
name?: string
24+
description?: string
25+
scope?: 'organization' | 'account' | null
26+
config?: { type?: 'cloud' | 'self_hosted' }
27+
}
28+
29+
interface RouteContext {
30+
params: Promise<{ id: string; envId: string }>
31+
}
32+
33+
/**
34+
* GET /api/managed-agent-connections/[id]/environments/[envId] — used by
35+
* the workflow-block tool at run time to resolve `envType` before
36+
* building the session-create payload.
37+
*/
38+
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
39+
const requestId = generateRequestId()
40+
try {
41+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
42+
if (!authResult.success || !authResult.userId) {
43+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
44+
}
45+
const userId = authResult.userId
46+
47+
const rawParams = await context.params
48+
const paramsValidation = managedAgentEnvironmentDetailParamsSchema.safeParse(rawParams)
49+
const queryValidation = managedAgentProxyQuerySchema.safeParse(
50+
Object.fromEntries(request.nextUrl.searchParams.entries())
51+
)
52+
if (!paramsValidation.success || !queryValidation.success) {
53+
return NextResponse.json({ error: 'Invalid request data' }, { status: 400 })
54+
}
55+
const { id, envId } = paramsValidation.data
56+
const { workspaceId } = queryValidation.data
57+
58+
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
59+
if (!userPermission) {
60+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
61+
}
62+
63+
const apiKey = await getDecryptedApiKey({ id, workspaceId })
64+
if (!apiKey) {
65+
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
66+
}
67+
68+
try {
69+
const body = await proxyManagedAgentsGet<AnthropicEnvironment>(
70+
apiKey,
71+
`/v1/environments/${encodeURIComponent(envId)}`
72+
)
73+
const envType = body.config?.type
74+
if (envType !== 'cloud' && envType !== 'self_hosted') {
75+
return NextResponse.json(
76+
{ error: 'Environment returned an unrecognised config.type' },
77+
{ status: 502 }
78+
)
79+
}
80+
const data: ManagedAgentEnvironment = {
81+
id: body.id,
82+
name: body.name ?? null,
83+
description: body.description ?? null,
84+
envType,
85+
scope: body.scope ?? null,
86+
}
87+
return NextResponse.json({ data })
88+
} catch (error) {
89+
if (error instanceof ManagedAgentProxyError) {
90+
const status = error.status === 404 ? 404 : 502
91+
return NextResponse.json({ error: error.message }, { status })
92+
}
93+
throw error
94+
}
95+
} catch (error) {
96+
logger.error(`[${requestId}] Failed to fetch environment`, error)
97+
return NextResponse.json(
98+
{ error: getErrorMessage(error, 'Failed to fetch environment') },
99+
{ status: 500 }
100+
)
101+
}
102+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import {
5+
type ManagedAgentEnvironment,
6+
managedAgentProxyParamsSchema,
7+
managedAgentProxyQuerySchema,
8+
} from '@/lib/api/contracts'
9+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
10+
import { generateRequestId } from '@/lib/core/utils/request'
11+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { getDecryptedApiKey } from '@/lib/managed-agents/connections'
13+
import {
14+
type AnthropicListPage,
15+
ManagedAgentProxyError,
16+
proxyManagedAgentsGet,
17+
} from '@/lib/managed-agents/proxy'
18+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
19+
20+
const logger = createLogger('ManagedAgentEnvironmentsAPI')
21+
22+
interface AnthropicEnvironment {
23+
id: string
24+
name?: string
25+
description?: string
26+
scope?: 'organization' | 'account' | null
27+
config?: { type?: 'cloud' | 'self_hosted' }
28+
}
29+
30+
interface RouteContext {
31+
params: Promise<{ id: string }>
32+
}
33+
34+
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
35+
const requestId = generateRequestId()
36+
try {
37+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
38+
if (!authResult.success || !authResult.userId) {
39+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
40+
}
41+
const userId = authResult.userId
42+
43+
const rawParams = await context.params
44+
const paramsValidation = managedAgentProxyParamsSchema.safeParse(rawParams)
45+
const queryValidation = managedAgentProxyQuerySchema.safeParse(
46+
Object.fromEntries(request.nextUrl.searchParams.entries())
47+
)
48+
if (!paramsValidation.success || !queryValidation.success) {
49+
return NextResponse.json({ error: 'Invalid request data' }, { status: 400 })
50+
}
51+
const { id } = paramsValidation.data
52+
const { workspaceId } = queryValidation.data
53+
54+
const userPermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
55+
if (!userPermission) {
56+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
57+
}
58+
59+
const apiKey = await getDecryptedApiKey({ id, workspaceId })
60+
if (!apiKey) {
61+
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
62+
}
63+
64+
try {
65+
const body = await proxyManagedAgentsGet<AnthropicListPage<AnthropicEnvironment>>(
66+
apiKey,
67+
'/v1/environments?limit=100'
68+
)
69+
const data: ManagedAgentEnvironment[] = (body.data ?? [])
70+
.map((row) => {
71+
const envType = row.config?.type
72+
if (envType !== 'cloud' && envType !== 'self_hosted') return null
73+
return {
74+
id: row.id,
75+
name: row.name ?? null,
76+
description: row.description ?? null,
77+
envType,
78+
scope: row.scope ?? null,
79+
}
80+
})
81+
.filter((row): row is ManagedAgentEnvironment => row !== null)
82+
return NextResponse.json({ data })
83+
} catch (error) {
84+
if (error instanceof ManagedAgentProxyError) {
85+
return NextResponse.json({ error: error.message }, { status: 502 })
86+
}
87+
throw error
88+
}
89+
} catch (error) {
90+
logger.error(`[${requestId}] Failed to list environments`, error)
91+
return NextResponse.json(
92+
{ error: getErrorMessage(error, 'Failed to list environments') },
93+
{ status: 500 }
94+
)
95+
}
96+
})

0 commit comments

Comments
 (0)