The ctx object
ctx is the second argument to your handler. It’s the one addition Turbyn makes to HubSpot’s signature, and it’s entirely optional.
exports.main = async (event, ctx) => { // ctx.hubspot: pre-authenticated CRM client // ctx.secrets: decrypted secret values // ctx.store: key-value store, atomic // ctx.log: structured logging // ctx.env: portal and run metadata // ctx.retry: retry helper for flaky calls};What a test run touches
Section titled “What a test run touches”Test runs come in two kinds, decided by your workspace role — not by a toggle in the UI. The editor shows you which one you got, and the run result names it.
Dry run — editors and admins. The default when you press Test.
Preview — members. A member can write and execute code, and gets real syntax and logic feedback, but the run holds no authority over your customer data in either direction. Testing against a real record needs an editor or admin.
| Live run (a real workflow, or Test with “apply”) | Dry run | Preview | |
|---|---|---|---|
| Event data | The record HubSpot enrolled | A real record you picked, fetched live | Only the synthetic sample you supplied |
ctx.hubspot reads |
Real | Real | Unavailable — throws |
ctx.hubspot writes |
Applied | Captured, reported as “would write” | Unavailable — throws |
ctx.store reads |
Real | Real, overlaid by this run’s own writes | Unavailable — throws |
ctx.store writes |
Applied | Captured to a per-run test namespace; production untouched | Unavailable — throws |
ctx.secrets |
Real values | Real values | Withheld — empty, and access throws |
Plain fetch() to a third party |
Real | Real — not intercepted | Real — not intercepted |
| Console output | Retained with the run | Retained with the run | Retained with the run |
Two things that are true in every column and worth saying plainly: a dry run is not a network sandbox — outbound HTTP to anywhere other than HubSpot happens for real — and console output is retained and readable by anyone with workspace access, whatever the run kind.
ctx.hubspot
Section titled “ctx.hubspot”A CRM client already authenticated as the portal that triggered the run. No token handling, no client construction.
const contact = await ctx.hubspot.contacts.get(event.object.objectId);
await ctx.hubspot.companies.update(companyId, { industry: "Software", numberofemployees: "250",});
const results = await ctx.hubspot.contacts.search({ filterGroups: [ { filters: [{ propertyName: "email", operator: "EQ", value: "j@example.com" }], }, ],});Rate limits are handled for you. The client backs off and retries on a 429 rather than surfacing it. Because your run isn’t racing a 20-second timeout, that backoff is affordable.
ctx.secrets
Section titled “ctx.secrets”Values from the secrets vault, decrypted at invocation and injected into the run.
const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY);Secrets are encrypted at rest, never returned to the browser after being saved, and excluded from logs. They exist for the lifetime of the run and nothing else.
Referencing a secret that doesn’t exist gives undefined rather than throwing. Check before use if it’s required.
Secret values are only ever decrypted into a run for a role that is allowed to see them. In a preview run they are withheld entirely — nothing is decrypted, and touching ctx.secrets throws with an explanation rather than silently handing back undefined. The result also carries a notice naming which bindings were withheld, so a preview failing on a missing key is never a mystery.
ctx.store
Section titled “ctx.store”A key-value store scoped to your portal. Operations are atomic, which matters because workflow actions run concurrently across enrolled records.
await ctx.store.set("last-sync", new Date().toISOString());const last = await ctx.store.get("last-sync");
// Atomic. Safe when 200 records are enrolled at once.const n = await ctx.store.increment("processed-count");
await ctx.store.delete("last-sync");Use it for counters, round-robin assignment cursors, deduplication markers and cross-run state. It is not a database: no queries, no indexes, no scans. Keys are strings; values are JSON-serialisable.
Round-robin assignment is the canonical case:
const reps = ["alice", "bob", "carol"];const n = await ctx.store.increment("rr-cursor");const owner = reps[n % reps.length];Because increment is atomic, two records enrolled simultaneously get different reps. A read-then-write would hand both the same one.
Scope, honestly
Section titled “Scope, honestly”ctx.store is scoped per portal, not per run and not per action. Every run for a portal — every enrolled record, every action, every test — reads and writes the same namespace. That is the point: a round-robin cursor is useless if run 2 can’t see what run 1 wrote. But it also means any action in the workspace can read or overwrite any key in that portal. Treat it as shared workspace state, not as private scratch space, and don’t put anything in it you wouldn’t want another action to read.
It is not shared across portals, and never across workspaces.
ctx.store during a dry run
Section titled “ctx.store during a dry run”On a dry run, set, delete and increment do not touch your real values. They are written to a private, per-run test namespace and reported back to the editor as “would write” entries, alongside the intercepted HubSpot writes.
Reads still work the way you’d want them to:
// Production has rr-cursor = 41.const n = await ctx.store.increment("rr-cursor"); // 42, inside this testawait ctx.store.set("marker", "test");await ctx.store.get("marker"); // "test" — you read back your own write// Production still has rr-cursor = 41 and no "marker" key at all.A read of a key the run hasn’t written returns the real production value. A dry run is an editor/admin action that already reads live CRM data, so reading live store values discloses nothing extra — but it does mean a dry run can print production state to the console.
The test namespace is discarded when the run finishes. Nothing carries over to the next test.
In a preview run, ctx.store is not available at all and calling it throws.
ctx.log
Section titled “ctx.log”Structured logging. Output streams to the run console live, and is retained with the run.
ctx.log.info("enriching", { domain });ctx.log.warn("no match found", { domain });ctx.log.error("provider rejected the request", { status: res.status });console.log is captured too, so pasted code that uses it still works. ctx.log is preferable because the second argument stays structured and filterable rather than being stringified.
ctx.env
Section titled “ctx.env”Read-only metadata about the run.
ctx.env.portalId; // 12345678ctx.env.actionId; // the saved action's idctx.env.versionId; // which published version is executingRun identity and authority live directly on ctx, not under ctx.env:
ctx.executionId; // stable across every attempt of this run — use as an idempotency keyctx.attempt; // 0 the first time your code runs for this executionctx.mode; // "live" | "dry_run" | "preview"ctx.mode is what you guard an external side effect on — the one thing the interceptor can’t know about:
if (ctx.mode === "live") { await sendSlackMessage(summary);}Turbyn intercepts HubSpot writes and ctx.store writes when ctx.mode isn’t "live". It does not and cannot intercept a plain fetch() to someone else’s API. A dry run that posts to Slack, charges a card or emails a customer really does it. Guard those yourself with the check above.
ctx.retry
Section titled “ctx.retry”Retry with exponential backoff, for flaky third parties.
const data = await ctx.retry( async () => { const res = await fetch("https://api.example.com/enrich"); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }, { attempts: 3, backoffMs: 1000 },);Only worth using where a retry is genuinely safe. Retrying a non-idempotent write can duplicate it. Combine with ctx.executionId as an idempotency key when the destination supports one.
Python
Section titled “Python”Identical surface, Python conventions:
async def main(event, ctx): contact = await ctx.hubspot.contacts.get(event["object"]["objectId"])
await ctx.store.increment("processed-count") ctx.log.info("processed", {"id": contact["id"]})
return {"outputFields": {"output1": "ok"}}