The Code of cStar
Ten opinions baked into the SDK. Read these once and the rest of the docs will make a lot more sense — most of them exist to enforce one of these rules.
If a future version of the SDK disagrees with this page, the SDK wins — file an issue and we'll fix the page.
Bare resources, not envelopes.
You shouldn't have to type ".data" every single time.
Single-object methods return the object. `cstar.tickets.create(...)` resolves to the ticket. `cstar.tickets.get(id)` resolves to the ticket. Same for update.
Metadata for the last call lives on `cstar.lastMeta` — `requestId`, `timestamp`, and `pagination` for list calls. Reach for it when you need it. Skip it when you don't.
List endpoints are the one exception. They return `{ data, pagination, hasMore }` because pagination is part of the answer, not metadata about it.
// Single-object — bare return
const ticket = await cstar.tickets.create({ title: 'Need help' });
console.log(ticket.id);
// Need the request ID for support? It's right there.
console.log(cstar.lastMeta.requestId);
// Lists — paginated envelope
const { data, pagination, hasMore } = await cstar.tickets.list({ status: 'open' });
console.log(`Page ${pagination.page} of ~${Math.ceil(pagination.total / pagination.pageSize)}`);Webhook events are the spine.
Polling is a tax. State changes should push, not pull.
Every state change in cStar fires a webhook. Tickets, customers, articles, messages, members, automations, billing — if something flipped, an event went out.
Build integrations off the event surface. A new ticket arrives → your CRM gets a row. A customer turns into a paid plan → your finance Slack channel pings. The data is already in motion. Hook into it.
Need to test locally without exposing a tunnel? `cstar listen --forward-to http://localhost:3000/webhooks`. Need to fire one without waiting for a real event? `cstar trigger ticket.created`.
Subscribe in dev. No ngrok required.
# Forward production events to your local server
cstar listen --forward-to http://localhost:3000/webhooks
# Fire a fake event to test the handler
cstar trigger ticket.createdTest mode is real mode.
Sandbox bugs that don't reproduce in prod waste your weekend.
`sk_test_*` and `sk_live_*` keys hit the same code paths. Same handlers. Same RLS policies. Same webhook dispatcher. The only difference is which database partition the data lands in.
`cstar.isTestMode` returns true if the client was constructed with a test-prefixed key. Use it to gate noisy logs or to label dashboards — don't use it to branch around bugs.
If something works in sandbox but breaks in live, the difference is your data, not our code. Start there.
const cstar = new CStarClient({
apiKey: process.env.CSTAR_KEY, // sk_test_... or sk_live_...
teamId: process.env.CSTAR_TEAM_ID
});
if (cstar.isTestMode) {
console.log('🧪 Running against test data');
}Idempotency is yours, not ours, to set.
You know when a retry is the same logical operation. We don't.
Pass `idempotencyKey` to any mutation and the SDK forwards it as the `Idempotency-Key` header. We dedupe within the standard 24-hour window — second request with the same key returns the cached first response.
Use it for anything triggered by a retry-able event: form submissions, webhook handlers, queue jobs. The key should be deterministic for the logical operation — `submission_${formId}_${userId}`, not a fresh UUID per attempt.
No key passed? You get exactly-once semantics on the network and at-least-once semantics on retries. That's your call to make.
// Webhook handler — same delivery may retry. Same key, same outcome.
app.post('/stripe-webhook', async (req, res) => {
const event = req.body;
await cstar.tickets.create(
{ title: `Stripe issue: ${event.type}`, priority: 'high' },
{ idempotencyKey: `stripe_${event.id}` }
);
res.status(200).end();
});Real-time is the default. Polling is the safety net.
The first message after a tab wakes up should already be on the screen.
`ChatClient` opens an SSE connection on subscribe. When the network blinks, it falls back to polling at the configured interval, then resumes streaming once SSE reconnects. You don't have to choose.
`cstar.realtime.on('ticket.*', handler)` does the same for the admin SDK. Server-Sent Events for the live stream, automatic reconnection with replay via `Last-Event-ID`.
Need polling-only for a hostile network? Pass `realtime: false` to `ChatClient`. The handler signature is identical — your UI doesn't care which path delivered the message.
// Live ticket events into your dashboard
const off = cstar.realtime.on('ticket.*', (event) => {
console.log(event.type, event.data);
});
// Tear down on logout
off();
cstar.destroy();Errors carry their own docs.
Catch by class, not by string. Strings change; classes don't.
Every error thrown by the SDK is a `CStarError` subclass: `CStarValidationError`, `CStarAuthenticationError`, `CStarRateLimitError`, `CStarNotFoundError`, `CStarConflictError`, `CStarPermissionError`, `CStarServerError`, `CStarFeatureNotConfiguredError`.
They all carry `code`, `requestId`, `docUrl`, `statusCode`, and the original `message`. The `docUrl` deep-links to the right section of `/developers/errors`. Paste the `requestId` into a support ticket and we can find the call in seconds.
`instanceof` works across every subpath — `/auth`, `/library`, `/community`, `/quickhelp` — because the class identity is registered on a `Symbol.for(...)` slot of `globalThis`. Bundle splits don't break the check.
import { CStarRateLimitError, CStarValidationError } from '@cstar.help/js';
try {
await cstar.tickets.create(formData);
} catch (e) {
if (e instanceof CStarRateLimitError) {
return retryAfter(e.retryAfter); // seconds, default 60
}
if (e instanceof CStarValidationError) {
return showFieldError(e.param, e.message); // e.param holds the offending field
}
// Any other CStarError — log the request ID and bail
console.error('Failed', e.code, e.requestId, e.docUrl);
throw e;
}The widget is the demo. The SDK is the escape hatch.
Drop in the embed when you want a working chat in five minutes. Reach for the SDK when you want it to feel like yours.
Most teams should start with the chat widget. One script tag, branded to your team's colors, configured from Settings → Widget. Done.
When the widget can't bend the way you need — a custom UI for a logged-in app, an in-product help center, an embedded assistant — graduate to `@cstar.help/js/chat`, `/library`, `/community`, `/quickhelp`, or `/proactive`. Same backend, your render layer.
You don't have to pick one. Run the widget on marketing pages and the SDK in the app. They share the same data; customers don't notice the seam.
Widget — copy, paste, ship.
<script
src="https://www.cstar.help/widget.js"
data-team-id="your-team-id"
defer
></script>Every response has a request ID. Use it.
"It's broken" without an ID is a guessing game. With one, it's a lookup.
Every response from the API carries `meta.requestId` in the body and `x-cstar-request-id` in the headers. The SDK captures both into `client.lastMeta` after every call. Errors expose it as `error.requestId`.
When you log a failure, log the request ID. When you contact support, paste it. We index against it; finding the call takes a second instead of a half-hour.
Webhook deliveries also carry `x-cstar-request-id` — set to the request ID of the API call that triggered the event. Trace from your dashboard click all the way through to your webhook handler with one string.
try {
await cstar.tickets.create(payload);
} catch (e) {
// Log this. Always.
log.error('ticket.create failed', {
code: e.code,
requestId: e.requestId,
docUrl: e.docUrl
});
throw e;
}Customers see calm. Agents see the cape.
Two audiences, two surfaces. The professional one is the contract; the gamified one is the soul.
Anything a customer touches — the chat widget, the public library, the community forum, your custom UI built on the SDK — stays clean and professional. No XP popups, no boss music.
Anything an agent touches — the dashboard, the side panel, the metrics board — is fully gamified. XP, achievements, boss battles, quests. Support work is a job worth showing up for.
Build with that line in mind. The SDK lets you compose either side. Don't mix them by accident.
Gamification ships on, but every agent can turn it off.
Some agents want the cape. Some just want to work. Both should be first-class.
Two switches govern the game layer. Boring Mode is per-agent, in Settings → Preferences → Boring Mode. It hides every XP popup, boss bar, and achievement toast — the agent sees clean professional metrics instead. XP keeps accumulating in the background, so flipping it back on doesn't lose progress.
Boss Battles is per-team, set in onboarding and editable in Settings → Features. Disable it and the team-wide boss feature is dormant — no Backlog Beast, no boss music, no shared HP bar. Individual XP and achievements still work for agents who want them.
Both toggles are reachable via the REST API for headless dashboards: `PATCH /api/v1/teams/{teamId}/settings/feature-flags` with `{ "bossBattlesEnabled": false }` flips the team-level switch (requires `manage_settings`), and `PATCH /api/v1/teams/{teamId}/members/{memberId}/preferences` with `{ "boringMode": true }` flips the per-agent one (requires `manage_members`). Storage: `teams.boss_battles_enabled` and `agents.boring_mode`.