External steps

A program is made of prompts; steps fetch things from outside it. Prompts change during a run. Steps do not.

Why you would want one

A prompt can only classify what it can see. If the right queue for a support ticket depends on the customer's plan and how many tickets they already have open, no wording of any prompt gets those rows right — there is a ceiling, and it is not a prompt problem.

A step closes that gap. It runs once per row, before any prompt, taking its inputs from the row's columns and making its answers available to every prompt as placeholders named {step_field} — {records_plan}, {records_open_tickets}. On the canvas it is deliberately a different shape: square, muted, labelled external · not optimized.

On the worked entitlement example the labels are generated from a rule that uses both the message and the record, so the accuracy ceiling without the step is 83% and with it 100%. That is a property of the data, computed from the generator, not a model result — which is exactly why it makes a fair test of the plumbing.

A step is declared, not coded

Each step is a manifest: a JSON file naming the endpoint, how to authorise it, what it needs, what it returns and where in the response those values live. Nothing about a particular service exists in Impromptune's own code — the test we hold ourselves to is that pointing a manifest at a different CRM changes no application code at all.

The manifest also declares two things people usually forget until production:

Manifests are curated by us and ship with the studio. There is no registry and no third-party submission path: nobody else's code runs on our box, or on yours.

How a step signs in

Two shapes, and the manifest says which are allowed:

A step key

You paste a secret the service issued you. It is encrypted at rest and scoped to your account. Use this when the provider issues keys, or when it is your own internal service. Nothing about it needs us to have a relationship with the vendor, which means no step is ever blocked on us.

A connection

An OAuth grant: you authorise Impromptune from inside your own account, on the provider's own consent screen, and you revoke it there. The step then acts as you. Tokens are encrypted, refreshed automatically, and never returned by any listing. This needs us to have registered an app with that provider, so it exists for the providers we have done that for.

Where both are possible the studio offers Connect if we have an app registered for that provider, and a paste box otherwise. Impromptune records the scopes the provider actually granted — not the ones we asked for — and warns if a step needs one that is missing. Where each kind of secret lives →

Frozen for training, live for serving

The step's answers are written onto your dataset once, at capture time, alongside the label — freezing. Every later run reads those frozen columns. Production calls the service live.

This is a correctness decision, not a cost one. A ticket is labelled escalation because the customer had two tickets open when it arrived. Re-fetch six weeks later and that count is zero, so the prompt is now being optimized to answer escalation from a record that no longer justifies it. The label and its features have come apart and no number in the run would show it. Freezing keeps the training set still; the alternative is optimizing against a moving target.

The difference is announced rather than hidden. A run reports the age of the frozen enrichment and warns past a threshold, and every served request records what the step was asked and what it answered — without that, an answer cannot be explained after the fact, because the data that produced it has moved on.

Validation, before you spend anything

Firing junk at an endpoint and checking it returns 200 tells you the server is up and nothing else. The failures that cost money are about the relationship between the step's output and your label — and almost none of that needs a model call. It is arithmetic over columns plus about forty HTTP requests.

CheckWhat it catches
CoverageHow many rows get a record at all. 40% empty means those rows are unlearnable, and you should know before spending.
StabilityThe same key called twice, compared. If it differs, caching is invalid and evaluation is not reproducible.
FreshnessHow far the frozen snapshot has drifted from live, and how old it is.
SignalWhether each field predicts the label better than chance, permutation-tested — and all the fields together, because a field that is useless alone can be load-bearing in combination.
DegeneracyA field constant across every row, or unique on every row. Carries nothing, costs tokens each call.
LeakageA field that predicts the label almost perfectly — usually the answer smuggled in. This one blocks the run.
RedundancyLeave one field out and re-measure. Answers “can I drop this?” directly, and lists what is droppable.
Payload wasteFields no prompt references. No extra call and no tokens — but data you are holding for no reason, which is a privacy finding rather than a cost one.

Tiers 0 and 2 cost zero model calls; the live probe is two HTTP requests and the statistics about forty. Only the pilot spends, and it was going to run anyway.

What these checks can and cannot promise. They answer “is the information there?”. They cannot answer “will the model use it?”, and the gap is real: on our own example one field carries measurable signal and survives leave-one-out, yet the class it should predict is 0/8 before optimization, 0/8 after, and 0/8 when the rule is written out by hand. The column has the signal; the model does not exploit it.

When a step fails

A timeout, a 5xx or a response that does not match the declared schema marks that row with an error and the run continues, recording step.<id>.fail on it. If more than a quarter of rows fail, the run aborts with the endpoint's own error rather than optimizing against a broken environment — handed a dead dependency, an optimizer will happily learn to work around it.