Skip to content

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
};

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.

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.

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.

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.

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.

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 test
await 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.

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.

Read-only metadata about the run.

ctx.env.portalId; // 12345678
ctx.env.actionId; // the saved action's id
ctx.env.versionId; // which published version is executing

Run identity and authority live directly on ctx, not under ctx.env:

ctx.executionId; // stable across every attempt of this run — use as an idempotency key
ctx.attempt; // 0 the first time your code runs for this execution
ctx.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.

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.

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"}}