Building Agents
This page walks through the four things almost every SDK agent combines: running the loop, human-in-the-loop approvals, custom tools, and playbooks. The full method and option tables are in the SDK Reference.
Run or stream
run() executes the loop to a terminal state and returns the result. stream() yields the same run as live events — text deltas, tool calls, plan and approval events:
const result = await CloudPeek.agent.run({
input: "Triage the new alerts",
instructions: "You are a security operations agent.",
maxIterations: 25,
});
// result.status: "completed" | "failed" | "incomplete" | "requires_action"
for await (const event of CloudPeek.agent.stream({ input: "..." })) {
// event.eventType / event.data
}
Approval gates only fire on stream() — the non-streaming path cannot pause for a human, so any agent with HITL must be driven through the stream.
Behaviour profiles (agents.md)
agentsMd layers a behaviour profile — tone, focus areas, workflow conventions — onto the system prompt. The SDK takes only the content; reading it is your concern (a local AGENTS.md file, a wiki page, a database row):
import { readFile } from "node:fs/promises";
await CloudPeek.agent.run({
input: "Triage the new alerts",
agentsMd: await readFile("AGENTS.md", "utf8"),
instructions: "You are a security operations agent.",
});
The composed prompt has a strict priority order: CloudPeek's core rules first, then the agents.md profile, then instructions. The profile can shape how the agent works but never overrides safety, authorization, tenant isolation, or tool-use rules. Omitting it leaves the prompt exactly as before, and a run suspended for client-side tools keeps its profile across resume().
Human-in-the-loop
Pick a mode per run, then answer approval events:
const stream = CloudPeek.agent.stream({
input: "Contain the compromised host",
hitlMode: "external_write_only", // reads flow, writes wait for a human
tools: [lookupTool, isolateTool],
});
for await (const event of stream) {
if (event.eventType === "response.tool.approval_required") {
const { approval_id, tool_name, tool_arguments } = event.data as {
approval_id: string;
tool_name: string;
tool_arguments: Record<string, unknown>;
};
const ok = await askYourUser(tool_name, tool_arguments);
ok ? CloudPeek.hitl.approve(approval_id) : CloudPeek.hitl.deny(approval_id);
}
}
The rules that matter:
- Modes.
external_write_onlygates external writes;external_onlygates every external call regardless of metadata;approval_onlygates everything;disabledgates nothing. These are the same modes as the CloudPeek app's HITL settings. - Fail-closed. A tool with no read-only declaration is treated as an external write and gated. Only a positive declaration skips the gate.
- Only the initiating user can approve.
approve/denyare scoped to theprincipal.userIdthat started the run; other callers are rejected and the approval stays pending. - Timeouts. Unanswered approvals auto-reject after five minutes, and the agent receives the rejection as context it can react to.
CloudPeek.hitl.pending()lists approvals the runtime is currently waiting on — enough to build an approval inbox.
Custom tools
In-process tools
Provide one executor callback at init, declare the tools per run so the LLM can see them:
await CloudPeek.init({
database: ":memory:",
principal: { tenantId: "local", userId: "operator" },
executeDynamicTool: async (_session, toolId, _method, params) => {
if (toolId === "lookup_asset") {
return { result: await cmdb.lookup(params.hostname), success: true };
}
return { result: `Unknown tool: ${toolId}`, success: false };
},
});
await CloudPeek.agent.run({
input: "Is web-01 patched?",
tools: [
{
type: "function",
name: "lookup_asset",
description: "Look up an asset in the CMDB",
parameters: {
type: "object",
properties: { hostname: { type: "string" } },
},
isReadOnly: true,
},
],
});
isReadOnly: true is your declaration that the tool has no side effects — in external_write_only mode it then skips write approval. Omit it and the tool is gated. Because your application is the operator, its declaration about its own tools is trusted.
Client-side tools
If a tool must run somewhere the SDK isn't — an analyst's laptop, a browser — declare it but provide no executor. The run suspends with requires_action and the pending calls; execute them on your side and resume:
let result = await CloudPeek.agent.run({
input: "...",
tools: [BASH_TOOL],
runId: "job-7",
});
while (result.status === "requires_action") {
const outputs = await Promise.all(
result.pendingToolCalls!.map(async (call) => ({
callId: call.call_id,
output: await runOnClient(call.name, JSON.parse(call.arguments)),
})),
);
result = await CloudPeek.agent.resume("job-7", outputs);
}
The handoff works from stream() too — the suspension is recorded from the stream's requires-action event, so a streaming UI can resume the same way.
Playbooks
Give the runtime a playbook source and the agent's built-in playbook_list and playbook_execute tools light up. Any object with search(tenantId, opts) and get(tenantId, id) works — a directory of markdown files, a database, or CloudPeek's own runbooks via a wiki-backed provider:
await CloudPeek.init({
database: ":memory:",
principal: { tenantId: "local" },
runbookProvider: myPlaybookSource,
});
await CloudPeek.agent.run({
input: "We have a phishing report",
instructions:
"Check playbook_list for a matching playbook before acting, and follow it when one exists.",
});
Without a provider, the playbook tools respond with a structured "not configured" message rather than failing the run.
Observing runs
Subscribe to lifecycle events — iterations, tool calls with redacted arguments, compaction, completion — for logging or metrics:
const off = CloudPeek.on("*", (event) => log.info(event));
// later: off();
And inspect durable state through CloudPeek.sessions — each run records a session you can get, list, or check status on, always scoped to the calling principal's tenant.