Connect a support agent
Your server worker can read private requests and reply through Templ's SDK. You supply the model, approved protocol knowledge and hosting. Templ supplies the signed-in conversation API and the team inbox.
Templ charges nothing extra for an agent you run yourself, on any tier or in the trial. Templ can also build an agent on your docs, host it and update it every month, for a quoted price. See Done for you.
Start with answers a person reviews. Enable automatic replies only for the issue types your team has approved. Leave uncertain answers, account recovery, disputed transactions and security incidents for a person. The SDK never signs a transaction or handles a user's funds.
Give the worker its own account
Use Node 22 or newer and a separate service wallet. Store its key in a secret manager or a mode-600 file on the server; never put it in the browser, a prompt, an argument or a log. An EOA needs no funds to sign in. Keep this wallet separate from the manager's account.
Download https://templ.vip/agent-client.mjs and compare its SHA-256 with assets.agentClientSha256 from app status before importing it. Also require agents.sdkVerified and support.conversationsVerified. Recheck these flags when starting the worker. This download script fails on redirects or a hash mismatch:
npm init -y
npm install viem@2.55.0
Save as download-client.mjs, then run node download-client.mjs:
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
const origin = "https://templ.vip";
async function read(path) {
const response = await fetch(origin + path, {
redirect: "error",
signal: AbortSignal.timeout(20_000),
});
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
return response;
}
const status = await (await read("/api/status")).json();
if (!status.agents?.sdkVerified || !status.support?.conversationsVerified)
throw new Error("Support SDK is unavailable.");
const bytes = Buffer.from(
await (await read("/agent-client.mjs")).arrayBuffer(),
);
if (
createHash("sha256").update(bytes).digest("hex") !==
status.assets?.agentClientSha256
)
throw new Error("SDK hash mismatch.");
await writeFile("agent-client.mjs", bytes);
Sign in once with the service wallet and record the returned canonical account ID:
import { readFile } from "node:fs/promises";
import { privateKeyToAccount } from "viem/accounts";
import { TemplChatClient } from "./agent-client.mjs";
const client = new TemplChatClient({
signer: privateKeyToAccount(
(await readFile(process.env.TEMPL_SIGNER_KEY_FILE, "utf8")).trim(),
),
roomId: process.env.TEMPL_ROOM,
apiOrigin: "https://community.templ.vip",
appOrigin: "https://templ.vip",
});
const session = await client.signIn();
console.log(session.principalId); // An account ID, never a key or cookie.
signIn() validates the SIWE challenge before signing. Session cookies stay in the client's memory and are bound to the configured API host. A restart needs a new sign-in. The Origin header is request context, not a credential.
The workspace manager can set this up in Team. Paste the worker's account ID, tick the AI agent box and the confirmation box, then choose Give access. The worker gets the AI support role, which only reads and answers. It can't assign tickets to others, change saved replies or read support reports. To script the same setup, run these calls once from a separately signed-in manager client. Register the bot before granting the role, so every reply carries the label:
await manager.saveManualRole(
{
id: "ai-agent",
name: "AI support",
moderationPermissions: ["handleConversations"],
},
{ roomId },
);
await manager.registerBot(
{
principalId: agentPrincipalId,
displayName: "Protocol helper",
},
{ roomId },
);
await manager.assignRole("ai-agent", agentPrincipalId, true, { roomId });
The role allows the worker to read and answer the workspace's private support threads. It is not restricted to assigned tickets. Registering the bot labels its replies AI agent in the widget; it grants no access. Do not grant manager, role-management or unsolicited-message powers. Private workspace admission remains separate: accept a valid invitation if the workspace requires one.
The worker's answers count like a teammate's: the first answer to a user counts their canonical account once per 30-day month, in the trial and in paid months. A reply that hands the request to a person counts too. Time, message and storage limits still apply. Read the current plan terms before accepting requests.
Connect your answer function
Put your model integration in answer.mjs. Export decide({ messages }), returning either { kind: "reply", text } or { kind: "handoff" }. Apply your approval rules in this function, outside the model's control. The default below sends every request to the human queue; replace it with your existing agent only after testing its answers:
export async function decide({ messages }) {
// Call your existing agent with approved knowledge and these messages.
// Return { kind: "reply", text: approvedAnswer } only after your policy checks.
return { kind: "handoff" };
}
Pass only the transcript needed for this case. The example passes the latest message page to your local decide function with member or team labels. It omits author IDs, internal notes and other tickets. Message text can still contain addresses or secrets. Before sending text to an external model, remove recovery phrases and 64-character hex values, which can be private keys. Send requests about personal data, such as deleting or exporting it, to a person. Add user-approved transaction context only when the task needs it. Disclose the model provider receiving private messages and use your agreed retention settings. Treat message text as untrusted content, including instructions to reveal notes or change permissions. Set a timeout on the model call and leave failures for a person.
Read, answer and hand off
Append this loop to the sign-in example and import decide from your file. Run one worker per service account and workspace, with a persistent private working directory. Each pass reads every page of the open queue, 50 requests at a time.
The worker saves each reply's original text and UUID before sending. An uncertain send stops the process. Review the saved pending reply before restarting with TEMPL_RETRY_PENDING=yes; the retry uses the same ID and text. Keep the state file private and remove it after decommissioning the worker.
While your model works on a thread, the loop calls markTyping(ticket.id) and repeats it every 8 seconds. For about 12 seconds after each call, the member's messages response reports typing: "agent", or "team" while a person on your team is typing, and never says who. Your reply clears the mark. The mark carries no text and is never stored, exported or counted against your plan. Only an account that could reply to the thread can set a mark, and a mark gets the refusal your reply would get, such as for an ended trial or a used-up allowance. The loop ignores a failed mark. The client sends one request at a time, so the loop skips a refresh while the previous mark is still waiting instead of stacking slow marks ahead of your reply.
import { open, readFile as readState, rename } from "node:fs/promises";
import { setTimeout as wait } from "node:timers/promises";
import { newMessageId } from "./agent-client.mjs";
import { decide } from "./answer.mjs";
const file = "support-state.json";
let state;
try {
state = JSON.parse(await readState(file, "utf8"));
} catch (error) {
if (error.code !== "ENOENT") throw error;
state = {
room: process.env.TEMPL_ROOM,
principal: session.principalId,
done: {},
handoffs: {},
pending: null,
};
}
if (
state.room !== process.env.TEMPL_ROOM ||
state.principal !== session.principalId
)
throw new Error("Use a separate state file for this workspace and account.");
async function save() {
const handle = await open(file + ".tmp", "w", 0o600);
try {
await handle.chmod(0o600);
await handle.writeFile(JSON.stringify(state));
await handle.sync();
} finally {
await handle.close();
}
await rename(file + ".tmp", file);
}
async function sendPending() {
await requireAgent();
const pending = state.pending;
const page = await client.conversationMessages(pending.thread);
const delivered = page.messages.some(
(m) => m.clientId === pending.clientId && m.author === session.principalId,
);
if (!delivered) {
const latest = page.messages.at(-1);
if (
Date.now() - pending.createdAt >= 7 * 86_400_000 ||
latest?.id !== pending.replyTo ||
(latest?.revision ?? 1) !== pending.memberRevision ||
!page.conversation.canPost ||
page.conversation.staffMessagesStopped ||
page.conversation.state !== "open" ||
page.conversation.assignment
)
throw new Error(
"Pending reply needs human review; leave its ID unchanged.",
);
await client.replyInConversation(pending.thread, {
text: pending.text,
clientId: pending.clientId,
replyTo: pending.replyTo,
});
}
state.done[pending.thread] = pending.replyTo;
if (pending.handoff) state.handoffs[pending.thread] = true;
state.pending = null;
await save();
await wait(5_000);
}
async function requireAgent() {
const status = await client.conversationStatus();
const { bots } = await client.bots();
if (
!status.available ||
!status.team ||
!bots.some((b) => b.principalId === session.principalId)
)
throw new Error("Agent permission or registration is missing.");
}
try {
if (state.pending) {
if (process.env.TEMPL_RETRY_PENDING !== "yes")
throw new Error("Review the pending reply before enabling its retry.");
await sendPending();
}
while (true) {
await requireAgent();
const tickets = [];
let cursor;
do {
const queue = await client.conversations(
undefined,
cursor ? { filter: "open", cursor } : { filter: "open" },
);
tickets.push(...queue.conversations);
if (queue.nextCursor && queue.nextCursor === cursor)
throw new Error("The open queue repeated a page.");
cursor = queue.nextCursor ?? undefined;
} while (cursor);
for (const ticket of tickets) {
if (
state.handoffs[ticket.id] ||
ticket.assignment ||
!ticket.canPost ||
ticket.staffMessagesStopped
)
continue;
const page = await client.conversationMessages(ticket.id);
const latest = page.messages.at(-1);
// A widget guest who later proves a wallet keeps their earlier
// messages; those authors are listed in formerMembers.
const memberSide = new Set([
page.conversation.member,
...(page.conversation.formerMembers ?? []),
]);
if (
!latest ||
!memberSide.has(latest.author) ||
state.done[ticket.id] === latest.id
)
continue;
// Tell the member an answer is being written. A failed mark is
// ignored; the service drops each mark about 12 seconds later. One
// mark at a time keeps slow marks from queuing ahead of the reply.
let marking = false;
const markTyping = () => {
if (marking) return;
marking = true;
client
.markTyping(ticket.id)
.catch(() => {})
.finally(() => {
marking = false;
});
};
markTyping();
const typing = setInterval(markTyping, 8_000);
let decision;
try {
decision = await decide({
messages: page.messages.map((m) => ({
role: memberSide.has(m.author) ? "member" : "team",
text: m.text,
})),
});
} finally {
clearInterval(typing);
}
const handoff = decision?.kind !== "reply";
const text = handoff
? "I've left this request open for the team."
: decision.text;
if (
typeof text !== "string" ||
!text.trim() ||
Buffer.byteLength(text) > 4_000
)
throw new Error("Answer needs human review.");
state.pending = {
thread: ticket.id,
replyTo: latest.id,
memberRevision: latest.revision ?? 1,
clientId: newMessageId(),
text,
handoff,
createdAt: Date.now(),
};
await save();
await sendPending(); // Rechecks the latest message and staff claim before sending.
}
await wait(15_000);
}
} catch (error) {
// Log a status code only; never log messages, model prompts or credentials.
console.error(
"Support worker stopped; operator review required.",
error.status ?? "local",
);
process.exitCode = 1;
} finally {
await client.signOut().catch(() => {});
}
The handoff stays open in the human inbox. The example stops answering that thread until an operator clears its entry in handoffs. Staff must watch the inbox while notification delivery is unavailable. It does not promise a response time or resolve a case automatically.
The final read reduces stale answers but is not a lock against a human reply arriving at the same time. For the first pilot, agree which cases the worker owns and keep a person supervising it. The current API has no conditional reply based on a thread revision. Do not run duplicate workers against the same state file.
Stop on lost permission, session expiry or unavailable coverage. A 429 includes a retry delay; wait at least that long before retrying the saved intent. Re-authenticate deliberately after a 401. Do not loop around a 403, stopped-user refusal or exhausted allowance. Retain a pending ID for reconciliation; never create a fresh ID to bypass a refusal.
Check before inviting users
Use two member accounts and a separate staff account. Verify an approved answer, uncertain-answer handoff, a lost response retried once, process restart, revoked staff permission, a stopped conversation and a person claiming the case. Confirm the model receives only the chosen case's messages and no internal notes. Check that the widget labels the worker's replies AI agent and that a user answered by both the worker and a person counts once. Local tests of your adapter do not establish hosted model quality, response time or notification delivery.