Zendesk Web SDK Agent
Do not test against your live ("production") Zendesk agent. Zendesk cannot tell test queries from real customer queries and may bill you the Automated Resolution price for every test query. Always test against a Zendesk sandbox — see Testing with a Zendesk sandbox.
This integration creates a Zendesk Web SDK conversation, sends a user message, and returns the first non-greeting business reply. It connects through Zendesk's Sunshine Conversations APIs.
Required secrets
| Secret | What it is |
|---|---|
SUBDOMAIN | Zendesk subdomain (without .zendesk.com). |
APP_ID | Sunshine Conversations app ID for REST endpoints. |
KEY_ID | Sunshine Conversations API key ID. |
SECRET | Sunshine Conversations API secret. |
SDK_APP_ID | Sunshine Conversations app ID for SDK bootstrap endpoints. |
SDK_INTEGRATION | Zendesk Web SDK integration ID used for conversation bootstrap and flow execution. |
Where to get the credentials
These come from your Zendesk Sunshine Conversations configuration: the API key ID and secret from your API key, the app IDs and Web SDK integration ID from your integration settings, and your account subdomain. Store each as a project secret.
Turn support
Multi-turn. The conversation is preserved across turns within a session, so it suits multi-turn evaluation.
Limitations and notes
- This integration requires the full set of Sunshine Conversations credentials above — partial configuration will not connect.
- Greeting messages are filtered out; the agent returns the first business reply.
How the agent works
The integration you copy from the registry is a single JavaScript function. This walkthrough follows the real code so you can adapt the same pattern to build your own custom agent connection.
The entry point is process(input). It loads its config, validates it, parses the
input into a mode, and dispatches. The whole body is wrapped in a try/catch so
any thrown error becomes a structured failure object:
export async function process(input) {
try {
const config = requiredEnv();
const missing = getMissingKeys(config);
if (missing.length > 0) {
return { ok: false, error: "missing_required_env", missing_keys: missing };
}
const parsed = parseInput(input);
const mode = String(parsed?.mode ?? "connect").trim().toLowerCase();
if (mode === "message") {
return await runMessageFlow(config, parsed);
}
return await runConnectProbe(config);
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
Secrets are read from the ambient env object and bundled into a single config:
const requiredEnv = () => ({
SUBDOMAIN: String(env.SUBDOMAIN ?? "").trim(),
APP_ID: String(env.APP_ID ?? "").trim(),
KEY_ID: String(env.KEY_ID ?? "").trim(),
SECRET: String(env.SECRET ?? "").trim(),
SDK_APP_ID: String(env.SDK_APP_ID ?? "").trim(),
SDK_INTEGRATION: String(env.SDK_INTEGRATION ?? "").trim(),
});
If any value is blank, getMissingKeys reports it and process returns early —
see the Required secrets table above for what each key is.
parseInput normalizes the raw input. An empty input becomes a connect probe; a
JSON string or object with text/query/question becomes a message; and the
object form can also carry a saved userId and conversationId from a prior turn:
const parseInput = (input) => {
const raw = String(input ?? "").trim();
if (!raw) {
return { mode: "connect" };
}
try {
const parsed = JSON.parse(raw);
// ... string -> { mode: "message", text }
// ... object with text/query/question -> { ...objectInput, mode: "message", text }
} catch {}
return { mode: "message", text: raw };
};
The integration uses two host paths off the same subdomain: the Sunshine
Conversations REST API (/sc/v2/apps/<APP_ID>) for messages, and the SDK bootstrap
endpoints (/sc/sdk/v2/apps/<SDK_APP_ID>) for starting a conversation. REST calls
authenticate with HTTP basic auth built from the key id and secret:
const buildAuthHeader = (config) => {
const credentials = `${config.KEY_ID}:${config.SECRET}`;
return `Basic ${toBase64Ascii(credentials)}`;
};
const requestJson = async (config, url, options = {}) => {
const response = await fetch(url, {
method: options.method ?? "GET",
headers: {
authorization: buildAuthHeader(config),
accept: "application/json",
...(options.body ? { "content-type": "application/json" } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
// ... returns { ok, status, payload, responseText }
};
Note requestJson does not throw on HTTP errors — it returns an { ok, status, payload, responseText } object and callers branch on read.ok.
When a turn has no existing session, the message flow bootstraps a user and conversation through the SDK endpoint with a generated client id and Smooch headers:
const createUserAndConversationViaSdk = async (config) => {
const clientId = generateClientId();
const endpoint = `${sdkBaseUrl(config)}/appusers`;
const payload = {
client: { platform: "web", id: clientId, integrationId: config.SDK_INTEGRATION, /* ... */ },
intent: "conversation:start",
locale: "en-us",
};
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
origin: `https://${config.SUBDOMAIN}.zendesk.com`,
"x-smooch-clientid": clientId,
"x-smooch-sdk": "web/zendesk/1.0.0+sha.7bfd60d",
"x-smooch-appid": config.SDK_APP_ID,
},
body: JSON.stringify(payload),
});
// ... returns { ok: true, appUserId, conversationId } or { ok: false, ... }
};
The message flow then records the ids of any pre-existing messages (so they are not mistaken for replies), posts the user's text, and hands control to the answer bot:
const postUserMessage = async (config, conversationId, userId, text) =>
await requestJson(
config,
`${baseUrl(config)}/conversations/${conversationId}/messages`,
{
method: "POST",
body: {
author: { type: "user", userId },
content: { type: "text", text },
},
},
);
It polls for a reply, filtering for messages whose author.type is business and
skipping greetings. extractBusinessText pulls the first usable text field, and
findReplyInMessages returns the first business message that is not a greeting:
const extractBusinessText = (message) => {
if (String(message?.author?.type ?? "") !== "business") {
return "";
}
const content = message?.content ?? {};
if (typeof content.text === "string" && content.text.trim()) {
return content.text.trim();
}
// ... markdown_text / html_text / alt_text fallbacks
return "";
};
const findReplyInMessages = (messages, seenIds, businessTexts) => {
for (const message of messages) {
const text = recordBusinessText(message, seenIds, businessTexts);
if (text && !isGreeting(text)) {
return text;
}
}
return null;
};
Greetings are matched against STARTER_PATTERNS (e.g. "hi there", "welcome", "how
can I assist you today"). If the first poll only yields greetings, the flow sends a
follow-up message ("Please answer this directly: ...") and polls again before
giving up with a reply_timeout error. On success it returns the plain reply text:
return polled.replyText;
Session state is preserved across turns through userId and conversationId. The
message flow reuses them when present and only bootstraps a new pair when they are
missing:
let userId = String(input?.userId ?? "").trim();
let conversationId = String(input?.conversationId ?? "").trim();
if (!userId || !conversationId) {
const boot = await createUserAndConversationViaSdk(config);
// ...
userId = boot.appUserId;
conversationId = boot.conversationId;
}
The error-handling convention is to return a structured object rather than
throw: runMessageFlow returns { ok: false, error: "...", ... } at each failure
point (missing input, SDK bootstrap failure, send failure, reply timeout), and the
top-level try/catch converts any unexpected throw into the same shape.
Adapting it for your own agent
To point this same skeleton at a different service, change:
- the REST and SDK base URLs (
baseUrl,sdkBaseUrl) and the auth scheme inrequestJson/buildAuthHeader; - the bootstrap request in
createUserAndConversationViaSdkand the message body inpostUserMessageto match your service's shapes; - the response fields you read for ids (
appUser._id,conversations[0]._id) and for reply text (author.type === "business", thecontentfields); - the polling cadence (the
maxTries/intervalMsarguments) and the greeting-filtering inSTARTER_PATTERNS/isGreeting; - how you preserve
userIdandconversationIdbetween turns; - the secret keys you read in
requiredEnvand declare in the table above.
Testing with a Zendesk sandbox
Why you can't test on production directly
Running a test against your live agent can be very expensive. Zendesk does not differentiate between real customer queries and test queries from Spec27, so it may charge the Automated Resolution price for every single test query.
For example, a single eval could cost roughly $800: 30 questions × (1 primary + 5 attack methods) × 3 variants × $1.50 per resolution charge.
How to avoid extra charges: use a Zendesk sandbox
A Zendesk sandbox is an isolated testing environment that mirrors your live agent. It's an exact copy of your production configuration, but you can run an unlimited number of queries for free.
You can get a sandbox in two ways:
- Upgrade to Suite Enterprise, which includes a sandbox. You can do this automatically via the Zendesk website without contacting support — it is a paid option.
- On Suite Team or Suite Professional, contact Zendesk customer support and ask for a sandbox. They typically offer a free sandbox trial of a higher plan for a month. For longer testing, you can purchase a sandbox add-on to your existing plan (coordinate with their support team).
Once you have a sandbox via a higher plan or add-on, Zendesk's own guide covers creating it.
Verify your plan includes a sandbox
Log into Zendesk and click the arrow next to Support to open the dropdown. Select Admin Center. Then navigate to Account → Billing → Subscription and confirm you have Suite Enterprise. If so, you're ready to set up the sandbox.
Create the sandbox
- Go to Admin Center.
- Open Account → Sandbox → Environments.
- Click Create Sandbox.
- Give it a clear name, for example
KB Testing Sandbox. - Choose ticket data replication: 0, 500, 5,000, or 10,000 tickets. Select 0 or 500 unless you need real ticket examples for context.
- Create the sandbox and wait until the status becomes Active. Replication can take from minutes to days depending on data volume.
- Open it from Account → Sandbox → Sandboxes by clicking Open Sandbox.
After you open the sandbox, confirm you are in the sandbox account and not production. For example, a production account named safeintelligence might have a sandbox named safeintelligence1777477838 — the URL or account name will typically include a numeric suffix.
Configure the sandbox for testing
Once inside the sandbox, verify the following so the agent has something to answer against.
Brand configuration
Go to Admin Center → Account → Brand management → Brands. Check the following:
- There is at least one brand.
- The brand name is correct.
- The Help Center is enabled for that brand.
- The brand is connected to the right subdomain or host mapping.
For testing, one default brand is usually enough.
Help Center / knowledge base
Look for Guide or Help Center in the product navigation, then navigate to Guide → Arrange content (or Help Center → Articles / Sections / Categories). Confirm:
- There are categories, sections, and articles.
- Articles are published, not only drafts.
If there are no categories or articles, the knowledge base is not set up and the agent cannot answer KB-grounded questions unless it uses another source.
Messaging channel / Web Widget
In the sandbox Admin Center, search for Messaging or Web Widget. Common locations include Admin Center → Channels → Messaging and social → Messaging or Admin Center → Channels → Classic → Web Widget. Look for a channel such as Web Widget, Web Messenger, Mobile SDK, or a social channel. For the JavaScript integration, focus on the Web Messenger or Web Widget messaging channel. Open it and verify:
- It is active.
- It is attached to the correct brand.
- A bot or AI agent is assigned to it.
- There is an installation snippet or integration ID.
- The widget is configured for the sandbox brand.
Bot / AI agent configuration
Search Admin Center for Bots and automations, AI agents, or Answer Bot / Flow Builder (typically under Admin Center → Bots and automations → Bots). Check:
- There is a bot or AI agent created.
- It is published or enabled.
- It is connected to the messaging channel.
- It uses Help Center articles (if applicable).
- It is configured for the sandbox brand.
- It has fallback or escalation behavior defined.
If there is no bot or AI agent, Spec27 has nothing to query yet — you will need to create and publish one, or copy the relevant production configuration to the sandbox.
Set it up
- Set up a Zendesk sandbox for testing — see Testing with a Zendesk sandbox above. Do not test against your production agent.
- Copy this integration from the registry — see Registry integrations.
- Create the project secrets above — see Project secrets.
- Preview the agent to confirm it works.