A task’s signature is two lists: vars, the values a run is given, and output, the fields it must hand back. Both are declared on the config, so the same definition can be run a thousand times with different inputs and every result comes back in the same shape.

Inputs: vars

A task declares its inputs exactly as an agent declares its variables — same field, same model, same {{vars.name}} templating. See variables for the templating rules themselves.
string
required
Letters, digits and underscores, starting with a letter or underscore. Read back in the prompt and in tools as {{vars.name}}.
string
default:""
For you and for the TaskCoPilot. Never substituted anywhere and never shown to the model.
string | null
default:"null"
What {{vars.name}} resolves to when the run supplies no value. Null means empty unless supplied.
boolean
default:"false"
Refuse the run rather than resolve this to nothing. A default satisfies it.
At most 25 variables.

What a run supplies

POST /v1/tasks/{task_id}/runs takes a flat {"vars": {name: value}} map of strings. Values are matched to declarations by name:
  • A name the run supplies wins over that variable’s default.
  • A name the run omits falls back to the default.
  • An empty string is a deliberate blank, not a request for the default.
  • A required variable with neither a value nor a default is refused before anything is compiled. No model is called and no tool runs, so the failure costs nothing.
  • A value for a name the task does not declare is refused, not dropped.
That last rule is the one difference from an agent. An agent’s key space is open — a request may carry a variable nothing declared, because an agent defined inline in that same request is free to read it. A task’s key space is closed, because a task’s variables are its input contract, and silently ignoring emial would produce plausible nonsense instead of an error. Both refusals are a 400 from the run endpoint, in the standard error shape. The undeclared-name one reads:
The values are also handed to the model as the message that opens the run, serialized as JSON. So a variable your prompt never interpolates is still visible to the model — but a prompt that reads {{vars.email}} where it matters is what makes the value land in the right place.

Output: output

A flat list of the fields the model must produce. At least one, at most 25. No arrays and no nested objects. A task that wants to return five talking points returns one string containing them. That is a stated limitation, and it is what keeps a run’s result mergeable into a flat column space downstream — which is how an email batch maps a task’s fields onto a CSV’s columns. Two rules are enforced when you save:
  • A name may not appear twice in output. Each name gets one type and one description, and the later entry would silently win.
  • A name may not be both a variable and an output field. A run’s inputs and its output are read side by side, so a collision would make one of the two unreachable. Rename one of each pair.

How the output is actually collected

The model does not “return” the output, and understanding what does happen is the difference between a task that finishes reliably and one that does not. When a run starts, the task gains one generated tool named submit_result, whose arguments are exactly your output fields:
A generated paragraph is appended to your prompt telling the model what that tool is for:
Both are written for you.
Never declare a tool named submit_result, and never write that paragraph into your prompt yourself. submit_result is reserved workspace-wide — creating a tool with that name is refused — and a hand-written copy of the paragraph only competes with the generated one.
Calling the tool ends the run: the arguments are validated against the schema above, stored as the run’s output, and no reply is generated afterwards. If the arguments do not match, the model is told what is wrong and may call it again. Calling it a second time after a valid result is refused.

Every field is required and nullable

In that tool, every field is required, and every field accepts null. The model has to say something about each one, including “I could not find it”. An optional field would let the model silently omit the address it failed to find — and then “omitted” and “not generated yet” look identical to whoever reads the row. One of those is a fact and the other is a bug, and nothing downstream can tell them apart. Requiring the field and allowing null keeps the difference visible. Which means the null case has to be in the description. The description is the only instruction the model gets about what belongs in that field — it becomes the argument’s description in the tool schema, so it is literally the prompt for the value.

Writing a description the model can act on

The pattern in the right-hand column: say what good looks like, say where to look, and say what to send when the answer is not findable. In the dashboard, the Inputs and output section flags an output field whose description is blank, because a blank one is a quality bug rather than a missing note.

Next

Tools and MCP servers

Give the task somewhere to look before it fills those fields in.

Running and runs

Supply the inputs, read the output, and follow the trace.