Destinations

A destination is where one output of one workflow lands. The workflow declares a name and writes to it; you pick the connector, the account and the folder from Studio, and the box (the container running your workspace, on your laptop under stackbone dev or deployed in your cloud) does the filing. The split is the point: the creator writes write('report', …) once, and moving that report from one Drive folder to another is a form, not a redeploy.

Two people decide two different things, and neither has to know the other's answer:

Who Decides Where
The creator What the workflow emits, by name in code, one exported list
The operator Where that name lands, per installation in Studio, the Destinations screen

This is not storage

stackbone.storage writes into the box's own bucket, with a key the creator composes. The files stay inside your installation and you browse them from Studio with signed URLs. A destination goes the other way: out to a third party, through a connector's writer, at an address only that connector understands.

Storage still takes part. The bytes are staged in a reserved bucket, _file-outputs, and the box is handed a reference rather than the bytes themselves. Once the document lands at the provider the staged object is deleted.

Declare what you write

One exported list, read by parsing the file, never by running it:

export const declaredOutputs = [
  { name: 'report', kind: 'file' },
  { name: 'notifyMail', kind: 'action' },
];

The names have to be literal. An operator binds (workflow, output name) to a folder before any run exists, so a name computed at run time is a name nobody can point at.

Then the workflow body names the output and nothing else:

const receipt = await stackbone.outputs.write('report', { fileName, contentType, bytes });
const result = await stackbone.action('notifyMail').call(operation, args);

No connector id, no folder, no account. write does not even take a workflow name: it reads the owner from the run in scope, so a run can only file into its own binding.

fileName is the name the document gets at the provider, Q3 report.pdf and not a storage key. contentType travels to the provider verbatim. bytes is a Uint8Array. And the call only works inside a workflow: a binding is keyed by the workflow, so a write with no run in scope throws rather than guessing one.

Two kinds

Kind What the operator picks What the workflow keeps
file a connector, an account, and a destination object (the folder) the bytes, the file name, the media type
action a connector and an account, nothing else the operation id and its arguments

An action is a call the workflow makes through a connection you bound. There is no folder to choose, so the binding stores an empty destination.

stackbone dev writes a declaration file into .stackbone/ so your editor autocompletes the names, and passing an action's name to write becomes a compile error. Before that file exists, any string is accepted.

A declaration it cannot read is dropped

Never fatal, always with a warning naming the file and the line:

What you wrote Why it is dropped
The export is not an array literal The names cannot be read without running your code
An entry is missing name, or computes it An operator binds to a name before any run exists
An entry is missing kind Guessing it is what this export exists to stop
A kind other than file or action The platform knows no other
The same name twice A binding is keyed by name and carries no kind, so the second would silently take the first one's destination

The older spelling, export const fileOutputs = ['report'], still works and means kind: 'file' for every name. When both appear in one file the new list wins, and the warning names the legacy line.

Bind it

The Destinations screen lists every declared output and every one still waiting. Binding asks for a connector, an account and, for a file, the folder.

A binding is keyed by the workflow name and the output name, and there can only be one. Binding an output that is already bound is an edit of the same row, not a sibling, and it keeps its id.

What the form accepts depends on what the code declared, never on what the form sends:

Declared kind Connector Destination
file must exist and must have a file writer must carry an id, and every parameter the connector requires
action must exist; a file writer is not required must be empty

Not every connector can write a file, which is why the check is split. The form only offers the ones that can, and the box refuses a file binding on one that cannot. Today Google Drive is the only connector shipped with a file writer, so a file output lands in a Drive folder; an action output can use any connector you have connected.

A redeploy does not touch it

Bindings live in the box's own database, not in the bundle you ship. Deploying a new version leaves every binding where it was, which is the whole point of the feature.

Renaming an output in code is the case to watch. The binding is keyed by the old name as plain text, so it survives, orphaned, and the screen keeps showing it rather than dropping it silently. Meanwhile the new name has no binding at all, so the next run parks. Bind the new name, then remove the old row.

Nothing is ever dropped

By the time a run reaches its output the expensive work is already done, so an output with nowhere to go is kept, not discarded. It parks.

What happened What you get reason
No binding for this name parked, waiting for you to point it somewhere unbound
The provider refused the write parked, with the provider's own message write_failed
The provider refused the call parked, with the provider's own message call_failed
This run already filed this output the earlier result, and the staged bytes are discarded not parked
The binding cannot be acted on a 409 and nothing parked destination_misconfigured

That last row is the one exception, and it is deliberate. A binding pointing at a connector the box does not know, or a folder with no id, is not something a retry fixes and not something you could act on from a button. It refuses loudly instead of leaving a row nobody can drain.

A parked row is born with one attempt already counted when a provider refused it, and with none when it was simply unbound.

Waiting to go out

The Destinations screen shows the waiting rows above the fold, not behind a tab. Each row carries its age, its attempt count, and a word computed from the binding as it stands now, not from when the row was written:

The row says It means
no destination / no connection Nothing is bound to this name today
not written / refused It is bound, and an attempt has already failed
ready It is bound and nothing has failed yet

Two counters sit at the top: how many items are waiting, and how many have nowhere to go.

Draining

One row at a time, by pressing a button: File it for a document, Send it for a call. This is not a sweep, and there is no timer behind it, because the person who can act on "the service account has no write access to that folder" is the person reading the screen.

A drain that the provider refuses is not an error. You get the provider's message and a bumped attempt count, and the row stays. A drain the box refuses before trying comes back as one of these:

Code What it means
already_filed / already_fired It already landed, or another drain is holding it right now
unbound Still nothing bound; refused before the claim, so no attempt is burnt
destination_misconfigured The binding itself is wrong; the attempt count is left alone
not_a_document / not_an_action The row's kind and the path disagree

Two drains cannot run at once. A claim is taken in the database, and it expires after five minutes so a box that dies mid-drain does not leave a row locked forever.

Filing twice is the thing to avoid

Creating a file at a provider is not repeatable: ask twice and you get two files. So the box records, per run and per output, the id of what it filed, and reads that record before it does anything else. A durable step whose response was lost retries into the record and gets the first id back.

A write with no run in scope is not deduplicated. A key with a missing part is not a key, and the box does not pretend otherwise.

Arguments are not on the list

A parked action shows the names of its arguments, never the values. Reading a value is one row at a time, and it writes a line to the Activity log first. If the trail cannot take that line, the read fails.

Where you see it

Surface What it carries
Destinations screen Everything waiting, why, how old, how many attempts, and the buttons
Activity log Binding, re-pointing, unbinding, a landed drain, and any reveal of an action's arguments
The run's own timeline What an action did, since a call records no file id
The box log, at error level The one dangerous case: the document is at the provider and recording it failed. That line is the only surviving copy of the id

A successful write records nothing on the Activity log. A document being filed is the run doing its job, and the runs surface owns that. A failed drain records nothing either, because the row already carries the error and the attempt count. The destination itself never appears on the trail.

Limits and retention

What Limit
Bindings per output Exactly one
Filed record per run and output Exactly one
Drain claim Five minutes
Retention on waiting rows None
Retention on filed records None

Nothing is swept. A settled row is kept so that months later you can still answer "where did that report from Tuesday end up". The filed table grows one row per document that really exists in your folder, so its row count is your folder count, with no amplification.

There is no size cap written into this path. What you can send is whatever your object store and the provider accept.

One name, two spellings

The product calls this destinations. Three tables underneath still say file_output, and the staging bucket is still _file-outputs. Open the DB explorer and you will see both words.

They are the same feature. The tables were created before the surface got its name, and they keep the old one: a table your box already serves is not renamed for cosmetics. If you find the old spelling anywhere, read it as this page.

Read more

  • Integrations: connections and the accounts a destination writes through.
  • Trigger events: the inbound half, what starts a workflow in the first place.
  • Storage: the box's own bucket, for files that stay inside your installation.
  • Workflows: where a declared output is written from.
  • Observability: the Activity log and the run timeline this page points at.
BUILT WITH ❤️ FROM CANADA AND SPAIN