Save a new version of a profile
The one way to define a profile (profile.define: the workspace owner or an admin), signed in; an API key is 403 whatever its scopes. The owner sends connection_id, runtime_kind and runtime_version (400 missing_field without them) and saves an installation-wide profile op_<name>_v<n>, whose provider the server copies in from the connection. An admin saves a profile of their own workspace that narrows a version they may see: derived_from names it for a first version, a later one names previous_id and keeps its source. The server copies the runtime, connection, billing and title model from the source, so an admin sending connection_id, runtime_kind, runtime_version, title_model or project_settings is 400 source_field, and a model, tool or mode wider than the source's is 400 widens_base; a source that is unknown, another workspace's or not usable is 400 unknown_base, retired or another state. A workspace profile's name is its display name; the server keeps it under a key it makes (wl_<uuid>) and installation.name answers the display name. A new profile whose name the workspace already sees is 409 name_taken. A new version of an existing profile sends its latest id as previous_id; anything else is 409 REVISION_CONFLICT (details.reason_code: stale_previous, details.latest_id), as is the second of two saves at once. A version that runs the same as the latest (the same models, tools, mode, provider and settings, whatever the deny spelling) writes nothing and answers with that latest version and a null receipt_id (the status stays 201: tell it from a new version by receipt_id); a retired or blocked latest takes a new version instead. A first owner version may name the profile it replaces in previous_id (400 unknown_previous when there is none). 400 details.reason_code: unknown_connection, runtime_not_installed, unattended_not_allowed (bypassPermissions while PLATFORM_ALLOW_UNATTENDED_PERMISSIONS is not true), model_not_in_models or invalid_profile, each with details.field. 409 details.reason_code: id_taken when the id is already an imported profile's. A profile names no repository: the request has no repository fields, and the repositories a session reaches come from the agent's GitHub settings. Its built-in tools are named one way: tools, kept as given, or deny, the tools of the runtime version's catalog (tool_catalogs in GET /v1/installation-profiles/options) to block, the rest saved as tools; deny: [] blocks none. deny may also name mcp__<server>__<tool> tools of the MCP servers the installation lists, matched exactly; they are kept beside the catalog's, listed as mcp_deny (the response deny stays the catalog's), and a next version keeps those of the one before. A version derived from a profile that blocks such names must name every one of them in deny (400 base_mcp_deny_dropped). 400 on deny (details.field: deny): no_tool_catalog (no catalog lists runtime_version), unknown_tool, tool_pattern (Bash(rm:*): whole built-in tool names only), mcp_tool_name (not mcp__<server>__<tool>, at most 128 characters, no spaces or control characters), github_server (the built-in server's tools follow the agent's GitHub settings) or unknown_mcp_server (new name of a server the installation does not list); accept_added_tools takes catalog tools only (mcp_tool). A next version lifts a held mcp__ block only by naming it in unblock_mcp_tools (400 details.field: unblock_mcp_tools: mcp_unblock_unknown when the previous version does not hold it, mcp_unblock_conflict when deny names it too, mcp_unblock_no_previous for a first version, base_mcp_deny_dropped when the base blocks it). With previous_id, a catalog tool that version never weighed (an allow list weighed only what it allowed) runs only when named in accept_added_tools, else 400 added_tool_not_accepted. Writes a create_installation_profile receipt naming the deny and tool_catalog_id saved.
Auth: cookie session only; no scope. Idempotency-Key: ignored.
cookieSession__Host-werft_session<token>For the web console: the browser session cookie set by POST /v1/auth/login. It holds the scopes of the user's workspace role (owner: all; admin and member: all but sessions:recover). Requests other than GET must also send X-Requested-With: werft-web.
X-Requested-With?"werft-web"Required with a cookie session: the literal werft-web.
"werft-web"application/json- body
name*string^[a-z0-9][a-z0-9-]{0,39}$connection_id?string1 <= length <= 200runtime_kind?string"claude_agent_sdk""codex_app_server""pi_coding_agent"runtime_version?string1 <= length <= 200model*string1 <= length <= 200models*array<>1 <= items <= 32title_model?string1 <= length <= 200tools?array<>The built-in tools sessions run, kept as given. Exactly one of tools and deny.
items <= 64deny?array<>The built-in tools of the runtime version's tool catalog to block, and mcp__<server>__<tool> names of the servers this installation lists (not the built-in github server); the server stores the rest of the catalog as tools. A derived version names every mcp__ name its base blocks; a next version keeps those of the one before. [] blocks none. Exactly one of tools and deny; 400 for a runtime version with no catalog.
items <= 64accept_added_tools?array<>With deny: catalog tools the previous version never weighed that this one may run. Any such tool neither here nor in deny is 400.
items <= 64unblock_mcp_tools?array<>mcp__<server>__<tool> names the previous version blocked that this next version no longer blocks: the only way to lift one, as a version otherwise keeps every mcp__ name of the one before. Each must be held by the previous version (400 mcp_unblock_unknown), not named in deny too (400 mcp_unblock_conflict) and not blocked by the base (400 base_mcp_deny_dropped); a version with no previous one cannot lift any (400 mcp_unblock_no_previous). A workspace version names its previous version in previous_id.
items <= 64permission_mode*string"default""acceptEdits""dontAsk""plan""bypassPermissions""auto"project_settings?previous_id?string1 <= length <= 200derived_from?string1 <= length <= 200The ceiling saved, and its receipt.
application/json- response
profile*receipt_id*|nullcurl -X POST 'https://example.com/v1/installation-profiles' \ -H 'Content-Type: application/json' \ -d '{ "name": "coding", "connection_id": "anthropic-main", "runtime_kind": "claude_agent_sdk", "runtime_version": "0.3.319", "model": "claude-sonnet-5", "models": [ "claude-sonnet-5", "claude-haiku-5" ], "title_model": "claude-haiku-5", "tools": [ "Read", "Edit", "Write", "Glob", "Grep", "Bash" ], "permission_mode": "acceptEdits", "project_settings": { "claude_md": true }, "previous_id": "op_coding_v1"}'{ "profile": { "id": "op_coding_v2", "source": "operator", "model": "claude-sonnet-5", "models": [ "claude-sonnet-5", "claude-haiku-5" ], "tools": [ "Read", "Edit", "Write", "Glob", "Grep", "Bash" ], "permission_mode": "acceptEdits", "runtime_kind": "claude_agent_sdk", "runtime_version": "0.3.319", "billing": "metered", "repository_mode": "required", "repository_ids": [ "sample-app" ], "installation": { "name": "coding", "version": 2, "origin": "console", "state": "usable", "connection_id": "anthropic-main", "title_model": "claude-haiku-5", "project_settings": { "claude_md": true }, "previous_id": "op_coding_v1", "created_by": "019a0000-0000-7000-8000-0000000000b1", "created_at": "2026-10-28T01:00:00.000Z", "retired_by": null, "retired_at": null, "blocked_by": null, "blocked_at": null } }, "receipt_id": "019a0000-0000-7000-8000-0000000000e3"}