Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6a5e7c9
feat(kiloclaw-agents): surface agent bindings in the controller read
St0rmz1 Jun 5, 2026
e9d138f
fix(kiloclaw-agents): surface bindings end-to-end + parse bindings once
St0rmz1 Jun 5, 2026
2102cca
feat(kiloclaw-agents): add controller binding-edit endpoint
St0rmz1 Jun 5, 2026
b43e79f
refactor(kiloclaw-agents): edit bindings via native atomic write, not…
St0rmz1 Jun 5, 2026
8161971
fix(kiloclaw-agents): summarize implicit main after binding update
St0rmz1 Jun 5, 2026
7399459
docs(kiloclaw-controller-spec): add agent binding endpoint + requirem…
St0rmz1 Jun 5, 2026
fb6ce38
fix(kiloclaw-agents): validate binding accountId so malformed entries…
St0rmz1 Jun 5, 2026
8c7a114
fix(kiloclaw-agents): normalize stored channel in binding conflict de…
St0rmz1 Jun 5, 2026
03b0aa5
feat(kiloclaw-agents): cloud DO/gateway passthrough for binding edits
St0rmz1 Jun 5, 2026
39670a5
feat(kiloclaw-agents): wire agent binding edits through the cloud con…
St0rmz1 Jun 5, 2026
e737ec6
fix(kiloclaw-agents): align binding classifier with OpenClaw semantics
St0rmz1 Jun 5, 2026
2e6f48c
fix(kiloclaw-agents): separate route match-scope from behavior overrides
St0rmz1 Jun 5, 2026
c928899
refactor. instead of going direct to the configs, use the cli
St0rmz1 Jun 8, 2026
d098e29
fix(kiloclaw-agents): harden CLI binding edits against partial applie…
St0rmz1 Jun 8, 2026
26ee607
fix(kiloclaw-agents): harden CLI binding edits (rollback, scope guards)
St0rmz1 Jun 8, 2026
c7fccfb
fix(kiloclaw-agents): make binding rollback atomic and report binding…
St0rmz1 Jun 8, 2026
0645117
fix(kiloclaw-agents): verify final binding state and unify failure re…
St0rmz1 Jun 8, 2026
14336ca
fix(kiloclaw-agents): report binding accountId verbatim; document man…
St0rmz1 Jun 8, 2026
06046a5
fix(kiloclaw-agents): match channel validation guards in tRPC binding…
St0rmz1 Jun 8, 2026
8fd9885
Merge remote-tracking branch 'origin/main' into feat/kiloclaw-agents-…
St0rmz1 Jun 8, 2026
f1da567
force blank commit to kick off CI
St0rmz1 Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .specs/kiloclaw-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ Version capability hint rules:
| POST | `/_kilo/config/agents` | Create a basic agent through non-interactive OpenClaw CLI behavior |
| PATCH | `/_kilo/config/agents/:agentId` | Update approved agent model/settings fields |
| DELETE | `/_kilo/config/agents/:agentId` | Delete a configured agent through non-interactive OpenClaw CLI behavior |
| PUT | `/_kilo/config/agents/:agentId/bindings` | Declaratively set an agent's channel-level (default-account) route bindings |
| PATCH | `/_kilo/config/agent-defaults` | Update approved inherited agent-default fields |
| POST | `/_kilo/config/tools-md/google-workspace` | Enable/disable managed Google Workspace `TOOLS.md` section |

Expand All @@ -675,11 +676,17 @@ Other values MUST return 400.
9. Native agent resource lookup and update requests MUST reject non-empty IDs that collapse to the reserved implicit `main` identifier rather than silently targeting `main`.
10. `DELETE /_kilo/config/agents/:agentId` MUST delegate deletion to non-interactive OpenClaw CLI behavior and MUST reject deletion of `main`.
11. The delete response MUST NOT claim verified filesystem deletion or verified file retention. Filesystem disposition is controlled by the installed OpenClaw CLI/runtime behavior and is not represented by the controller response.
12. The following capability hints MUST be advertised when the corresponding CRUD routes are registered: `config.agents.read`, `config.agents.create.basic.cli`, `config.agents.update`, `config.agents.delete.cli`, and `config.agent-defaults.update`.
12. The following capability hints MUST be advertised when the corresponding CRUD routes are registered: `config.agents.read`, `config.agents.create.basic.cli`, `config.agents.update`, `config.agents.delete.cli`, `config.agents.bindings.update`, and `config.agent-defaults.update`.
13. Native updates MUST report stale config etags or config changes observed before commit as `409 config_etag_conflict`.
14. Controller-originated agent create, update, defaults-update, and delete mutations MUST be serialized per config path so a lifecycle CLI mutation cannot be overwritten by a concurrent native controller update.
15. CLI lifecycle operations MUST report known reserved/not-found/conflict validation failures using stable HTTP error codes, and MUST report timeout or malformed/process failures without exposing secret environment values.
16. Controller server errors from agent-config reads MUST NOT expose filesystem error details in HTTP responses.
17. Agent read summaries MUST include the agent's routing bindings as reported by non-interactive OpenClaw CLI binding behavior (the routing source of truth), surfacing channel, account id (verbatim; absent account → null), and a flag for bindings more specific than a plain channel(/account) route (e.g. peer/guild/team/roles matches). The CLI binding listing covers routing bindings only; non-route binding types are out of scope for this surface and need not be reported. Every response that returns an agent summary (list, read, create, settings-update, bindings-update) MUST carry these CLI-sourced bindings rather than an empty placeholder.
18. `PUT /_kilo/config/agents/:agentId/bindings` MUST declaratively set the agent's channel-level default-account routes to exactly the requested channel set by delegating to non-interactive OpenClaw CLI binding behavior (diffing the CLI's current view and issuing bind/unbind). It MUST be serialized per config path with the other agent mutations, and MUST report a stale request etag as `409 config_etag_conflict` before issuing CLI writes. It MUST report success only after confirming, from a fresh CLI read, that the agent's managed channel routes equal the requested set; if they differ (e.g. a requested channel resolved to an existing account-scoped route and was reported `skipped`, or an unbind resolved to a different scope and was reported `missing`) it MUST roll back and fail with `422 invalid_agent_config` rather than report a result it did not achieve.
19. The binding set MUST manage only channel-level default-account routes; account-scoped routes, advanced bindings (peer/guild/team/roles or non-`route` type), and other agents' bindings MUST be left intact. Every post-write step through the final managed-set verification runs under one recovery path (see rule 20): any failure restores the pre-change routing.
20. The binding set MUST report a requested channel that the CLI rejects as already routed to another agent as `409 agent_binding_conflict`. Because OpenClaw applies non-conflicting additions before reporting a conflict, on any rejection the binding set MUST roll back only the routes that invocation created — identified by diffing against the pre-change snapshot, never by blindly unbinding the requested channels (a bare unbind can resolve to and delete a pre-existing account-scoped route) — and MUST NOT remove any pre-existing route. It MUST then confirm the agent's routing was restored to the pre-change snapshot; if restoration cannot be confirmed it MUST fail with `500 agent_binding_rollback_failed` rather than report a clean rejection over mutated routing.
21. Binding edits MUST target an agent present in `agents.list`; a request for an absent agent (including unconfigured implicit `main`) MUST return `404 agent_not_found` rather than misrouting to the default agent.
22. Managed scope and concurrency (documented limits): the binding set authors and manages only channel-key-only default routes (match with a `channel` key and nothing else). A route carrying any `accountId` — including a blank/whitespace value, which this endpoint never authors — MUST be surfaced verbatim in reads (per rule 17) and treated as account-scoped (left unmanaged), never coerced to a default route. The endpoint assumes at most one managed route per channel (the shape it authors); externally-authored states it cannot produce (duplicate route matches, account-scoped or non-`route` bindings) are out of its management scope. Concurrency with non-agent config writers (`/_kilo/config/patch`, `/_kilo/config/replace`, restore) is bounded by the same config-wide etag model as the other agent mutations; this endpoint does not add stronger cross-writer serialization.

#### Environment (bearer token)

Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/lib/kiloclaw/agent-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from '@jest/globals';
import {
AgentBindingsInputSchema,
AgentCreateInputSchema,
AgentDefaultsUpdateInputSchema,
AgentIdSchema,
Expand Down Expand Up @@ -142,3 +143,30 @@ describe('AgentDefaultsUpdateInputSchema', () => {
expect(AgentDefaultsUpdateInputSchema.safeParse({}).success).toBe(false);
});
});

describe('AgentBindingsInputSchema', () => {
it('accepts a channel list (and an empty list to clear routes)', () => {
expect(AgentBindingsInputSchema.safeParse({ channels: ['slack', 'discord'] }).success).toBe(
true
);
expect(AgentBindingsInputSchema.safeParse({ channels: [] }).success).toBe(true);
expect(AgentBindingsInputSchema.safeParse({ etag: 'e1', channels: ['slack'] }).success).toBe(
true
);
});

it('rejects an empty channel value', () => {
expect(AgentBindingsInputSchema.safeParse({ channels: [' '] }).success).toBe(false);
});

it('rejects channels with controller-guarded formats (dash prefix, account specifier)', () => {
expect(AgentBindingsInputSchema.safeParse({ channels: ['-foo'] }).success).toBe(false);
expect(AgentBindingsInputSchema.safeParse({ channels: ['slack:team'] }).success).toBe(false);
});

it('rejects unknown keys (strict)', () => {
expect(AgentBindingsInputSchema.safeParse({ channels: ['slack'], nope: true }).success).toBe(
false
);
});
});
28 changes: 28 additions & 0 deletions apps/web/src/lib/kiloclaw/agent-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,34 @@ const AbsolutePathSchema = z
.max(1024)
.refine(value => value.startsWith('/'), { message: 'Path must be absolute' });

// Declarative channel-route set (PUT /_kilo/config/agents/:id/bindings).
// `channels` is the agent's full channel-level route set (single-account cloud).
export const AgentBindingsInputSchema = z
.object({
etag: z.string().min(1).max(128).optional(),
// Guards mirror the controller's AgentBindingsPutBodySchema so invalid
// channels are rejected here with a clear message instead of a generic
// controller 400: no leading dash (flag-like) and no `:` account specifier
// (this endpoint manages only channel-level default-account routes).
channels: z
.array(
z
.string()
.trim()
.min(1)
.max(64)
.refine(value => !value.startsWith('-'), {
message: 'Channel must not begin with a dash',
})
.refine(value => !value.includes(':'), {
message: 'Channel must not include an account specifier',
})
)
.max(50),
})
.strict();
export type AgentBindingsInput = z.infer<typeof AgentBindingsInputSchema>;

// Create body (POST /_kilo/config/agents).
export const AgentCreateInputSchema = z
.object({
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/kiloclaw/kiloclaw-internal-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,23 @@ export class KiloClawInternalClient {
);
}

async updateAgentBindings(
userId: string,
agentId: string,
bindings: Record<string, unknown>,
instanceId?: string
): Promise<AgentMutationResponse> {
const params = instanceId ? `?instanceId=${encodeURIComponent(instanceId)}` : '';
return this.request(
`/api/platform/agents/${encodeURIComponent(agentId)}/bindings${params}`,
{
method: 'PUT',
body: JSON.stringify({ userId, bindings }),
},
{ userId }
);
}

async deleteAgent(
userId: string,
agentId: string,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/lib/kiloclaw/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,12 @@ export type AgentSettingsSummary = {
fastModeDefault: boolean | null;
};

export type AgentBindingSummary = {
channel: string;
accountId: string | null;
advanced: boolean;
};

export type AgentSummary = {
id: string;
name: string | null;
Expand All @@ -563,6 +569,7 @@ export type AgentSummary = {
model: AgentModelSummary & { source: 'agent' | 'defaults' | null };
rawModel: string | { primary?: string; fallbacks?: string[] } | null;
settings: AgentSettingsSummary;
bindings: AgentBindingSummary[];
};

export type AgentDefaultsSummary = {
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/routers/kiloclaw-router-agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const agentMocks = {
updateAgent: jest.fn(),
updateAgentDefaults: jest.fn(),
deleteAgent: jest.fn(),
updateAgentBindings: jest.fn(),
};

jest.mock('@/lib/kiloclaw/kiloclaw-internal-client', () => {
Expand Down Expand Up @@ -175,6 +176,26 @@ describe('kiloclaw agent procedures (personal namespace)', () => {
expect(agentMocks.deleteAgent.mock.calls[0][1]).toBe('work');
});

it('updateAgentBindings forwards agentId and the bindings payload', async () => {
agentMocks.updateAgentBindings.mockResolvedValue({
ok: true,
etag: 'e2',
agent: AGENT_SUMMARY,
});
const caller = await createCallerForUser(personalUser.id);

await caller.kiloclaw.updateAgentBindings({
agentId: 'work',
bindings: { etag: 'e1', channels: ['slack'] },
});

expect(agentMocks.updateAgentBindings.mock.calls[0][1]).toBe('work');
expect(agentMocks.updateAgentBindings.mock.calls[0][2]).toEqual({
etag: 'e1',
channels: ['slack'],
});
});

it('rejects invalid input before calling the client (non-absolute workspace)', async () => {
const caller = await createCallerForUser(personalUser.id);

Expand Down Expand Up @@ -274,6 +295,24 @@ describe('kiloclaw agent procedures (org namespace)', () => {
).rejects.toMatchObject({ code: 'CONFLICT' });
});

it('updateAgentBindings forwards the bindings payload for an org instance', async () => {
agentMocks.updateAgentBindings.mockResolvedValue({
ok: true,
etag: 'e2',
agent: AGENT_SUMMARY,
});
const caller = await createCallerForUser(orgOwner.id);

await caller.organizations.kiloclaw.updateAgentBindings({
organizationId: orgId,
agentId: 'work',
bindings: { channels: ['slack'] },
});

expect(agentMocks.updateAgentBindings.mock.calls[0][1]).toBe('work');
expect(agentMocks.updateAgentBindings.mock.calls[0][2]).toEqual({ channels: ['slack'] });
});

it('returns NOT_FOUND when the org has no active instance', async () => {
// personalUser is not a member with an org instance under a fresh org id.
const otherOrg = await createTestOrganization('No Instance Org', orgOwner.id, 1_000_000);
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/routers/kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AgentCreateInputSchema,
AgentUpdateInputSchema,
AgentDefaultsUpdateInputSchema,
AgentBindingsInputSchema,
} from '@/lib/kiloclaw/agent-schemas';
import { kiloclawFilePathSchema } from '@/lib/kiloclaw/file-path-schema';
import { pushPinToWorker } from '@/lib/kiloclaw/pin-sync';
Expand Down Expand Up @@ -4723,6 +4724,23 @@ export const kiloclawRouter = createTRPCRouter({
}
}),

updateAgentBindings: clawAccessProcedure
.input(z.object({ agentId: AgentIdSchema, bindings: AgentBindingsInputSchema }))
.mutation(async ({ ctx, input }) => {
try {
const instance = await getActiveInstance(ctx.user.id);
const client = new KiloClawInternalClient();
return await client.updateAgentBindings(
ctx.user.id,
input.agentId,
input.bindings,
workerInstanceId(instance)
);
} catch (err) {
handleFileOperationError(err, 'update agent bindings');
}
}),

// ── Billing endpoints ────────────────────────────────────────────────

getBillingStatus: baseProcedure.query(async ({ ctx }) => {
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/routers/organizations/organization-kiloclaw-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AgentCreateInputSchema,
AgentUpdateInputSchema,
AgentDefaultsUpdateInputSchema,
AgentBindingsInputSchema,
} from '@/lib/kiloclaw/agent-schemas';
import { kiloclawFilePathSchema } from '@/lib/kiloclaw/file-path-schema';
import { pushPinToWorker } from '@/lib/kiloclaw/pin-sync';
Expand Down Expand Up @@ -1706,6 +1707,29 @@ export const organizationKiloclawRouter = createTRPCRouter({
}
}),

updateAgentBindings: organizationMemberMutationProcedure
.input(
z.object({
organizationId: z.uuid(),
agentId: AgentIdSchema,
bindings: AgentBindingsInputSchema,
})
)
.mutation(async ({ ctx, input }) => {
try {
const instance = await requireOrgInstance(ctx.user.id, input.organizationId);
const client = new KiloClawInternalClient();
return await client.updateAgentBindings(
ctx.user.id,
input.agentId,
input.bindings,
workerInstanceId(instance)
);
} catch (err) {
handleFileOperationError(err, 'update agent bindings');
}
}),

// ── Org-wide instance list (owner / billing_manager only) ─────

listActiveInstances: organizationBillingProcedure.query(async ({ input }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('getControllerEndpointCapabilities', () => {
'config.agents.create.basic.cli',
'config.agents.update',
'config.agents.delete.cli',
'config.agents.bindings.update',
'config.agent-defaults.update',
])
);
Expand Down
1 change: 1 addition & 0 deletions services/kiloclaw/controller/src/endpoint-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const CONTROLLER_ENDPOINT_CAPABILITIES = [
'config.agents.create.basic.cli',
'config.agents.update',
'config.agents.delete.cli',
'config.agents.bindings.update',
'config.agent-defaults.update',
'config.tools-md.google-workspace',
'env.patch',
Expand Down
Loading
Loading