Collections and Outputs

A loop keeps what its runs produce in collections: named sets of records with declared fields. It sends a result out of the platform through its outputs: a signed webhook to your system, or an email.

Declare a collection

A run may write only the collections its loop declares:

collections:
  - name: leads
    key: email
    fields:
      email: string
      company: string
      company_size: string
      score: number
KeyWhat it says
nameThe collection’s name, such as leads.
keyThe field that identifies a record. Its value becomes the record’s id.
fieldsEach field and its type: string, number, boolean, date, file or row, or generated for the key alone. A write that carries a field the declaration does not have is refused. A file field holds a file a reader uploads through a page form (Let readers upload a file); only that upload fills it, and a loop’s run or an automation that writes one is refused.
sharedtrue when other loops in the account may read and write this collection too.
choicesOptional. The only values a string field takes, by field: status: [open, doing, done]. See Fixed values for a field.

The collection is created when you save the draft that declares it, so you can try the loop on My conversations at once. A run writes records with records_put and reads them with records_query.

Let the platform make the key

When nothing in a record names it, such as a task or a ticket, declare the key generated:

collections:
  - name: tasks
    key: task_id
    fields:
      task_id: generated
      title: string
      status: string
  • To create a record, the run leaves task_id out. The platform makes a new id, a UUIDv7, and the answer to records_put names it. Three writes make three records.
  • To edit a record, the run sends the task_id of a record it created or read, with the fields to change. The fields it leaves out keep their values, and the record keeps the time it was created. A task_id that no record holds is refused, and the refusal tells the model to leave the key out to create a record. So a model can never choose an id.
  • Only the key can be generated, and such a collection takes no writes: create, because every new record has a new key. Saving the draft refuses both, and names the fix.
  • A page form on such a collection leaves the key out too: each save makes a new record.

To keep what a person types in a record, see Keep a value in a collection without the model.

Fixed values for a field

A string field can name the only values it takes, such as a task’s status: open or done. On Collections, press New collection, tick Only these values under the field, and type them, separated by commas. The card names them before you press Create: “Adds status (Text, only open, done).”. With the API, give the field "choices": ["open", "done"] in its declaration.

To change the values of a field the collection already has, press Change fields of the collection, and edit the values beside the field. Clear them to remove them all. The card says the values now and after the press before you press Save: “Changes the choices of status. Now: open, done. After the press: open, doing, done.”.

  • At most 50 values, each 1 to 100 characters. The console drops repeats.
  • Only a string field takes choices; the review refuses another type and names the fix.
  • A write sets a field’s choices to what it sends, so a declaration that sends a field back as the collection lists it keeps them.
  • Removing a value does not change the records that hold it. The card says how many hold each value you remove before you press Save: “1 record holds “blocked”; it keeps that value and no form offers it.” A collection of more than 500 records reads “At least”. A page’s board shows such a record in a column of its own value, said as not one of the field’s values.

In a loop, list them under the collection’s choices, in the order a page shows them:

collections:
  - name: tasks
    key: task_id
    fields:
      task_id: generated
      title: string
      status: string
    choices:
      status: [open, doing, done]
  • Saving the draft refuses a list on a field that is not string, an empty list, and a value listed twice, and names the fix.
  • A value outside the list is refused, naming the list: a run’s records_put, a page form, a row’s Edit, an action button and a block’s where= all check it.
  • Approving a version that lists different values for a field sets that field’s list to them. A field the version lists none for keeps the values it has.
  • A page shows a choice field’s values as labels, asks for it with a list to choose from, and offers every value as a filter button.

A field that refers to a row

A row field holds the record id of one row of a collection, such as the task a comment is on, or a row of its own collection, such as a task’s parent task. On Collections, give the field the kind A row of a collection and choose its collection under A row of…, where a new collection lists itself as “(this collection)”; the card names it: “Adds task (A row of tasks).”. With the API, give the field "type": "row", "of": "tasks". The claim keeps it under the collection’s refs: refs: {task: tasks}.

  • The collection must be one the account has; the review refuses another and names the ones it has.
  • A collection has at most one row field.
  • A write whose row field names no row of its collection is refused, naming the collection, and nothing is written.

Who may change or remove rows from a page

A collection says who may change and who may remove its rows from a page, in access. Without it, no page changes or removes a row:

{"access": {"change": {"by": ["member", "user"], "rows": "own"},
            "remove": {"by": ["admin"], "rows": "any"}}}
  • by names the readers who hold the right: any of anonymous, user, invited, member and admin. An invited reader’s own rows are the ones they added from a page that invited them.
  • rows is own (only the rows that reader added) or any (every row).
  • A record remembers who added it: the person or visitor whose run or form wrote it. Records written before 2026-10-18, by a pipeline, or by an anonymous visitor have no one who added them, so they are nobody’s own. An own right for anonymous is refused for that reason.
  • A page may only narrow this: fewer readers, or own in place of any (Who may change rows).
  • Set it on Collections: open the collection, which says who may change and remove its rows now, and press Who may change rows of …. This is the place for every collection, one a published loop declares too: the loop owns its key, fields and choices, and the rights are yours. A change to the fields of a loop’s collection is refused here, naming the loop. Or set it with the collection’s declaration, PUT /api/workspace/collections/<name> with an access key. A declaration without access keeps the one the collection has, and so does a loop that widens the collection; "access": {} removes it.
  • The card that comes before the write says who may change and remove the rows once you press, and for a change to them, who may now and who may after.

A page’s Edit, Delete and action buttons use it: see Let readers change and remove rows.

One collection, one loop, unless it is shared

A collection belongs to the account, and its name is its identity. If two loops declare the same name, they read and write the same records. So saving a draft refuses a name that another loop already declares, unless every loop that declares it says shared: true. The refusal names the collection, the other loops, and both fixes: rename it, or declare it shared everywhere. Two loops that already declare the same name can each be saved while you add shared: true to them one at a time; a save that adds a new clash is still refused.

Change a collection

A later version can add fields: records written before simply lack them. Saving the draft refuses an edit that removes a field the loop declared before (in its published version or its saved draft), or changes a field’s type or key, and names the fields: keep them, or declare a new collection. A loop never removes a field it did not add: a field you, a pipeline or another loop added stays, and leaving it out refuses nothing.

Ask LoopAssistant for a collection or a field

You can ask LoopAssistant for a collection, or for a change to one you have: “add a due date to my tasks”, “let status be blocked too”. It proposes the change on a card, and approving installs it.

  • For a new collection, the card shows it whole: every field with its type and choices, and the field that names each record. Nobody may change or remove its rows from a page until you say so on the Collections page.
  • For a change, the card shows only what changes: the fields it adds, and a field’s choices before and after. Every field it does not name stays as it is.
  • LoopAssistant never changes who may change or remove a collection’s rows: you set that on the Collections page.
  • A change from LoopAssistant only adds. It never removes a field or a choice; to remove a choice, edit the collection on the Collections page, whose card says how many records hold each value it removes.
  • Create (or Save, for a change) installs it; Decline leaves the collection as it is.
  • The card runs the same checks as the Collections page. A change that would change a field’s type or the key is refused, naming the field, and a collection a published loop declares is changed in that loop.
  • When a page you ask for needs a field the collection lacks, LoopAssistant proposes the field first, then the page. Saving such a page yourself is refused in words that say to add the field to the collection first.

An automation that writes a collection

An automation step that writes a declared collection is held to its field types and its fixed values, as a run’s records_put is. If one row has a value of another type, or a value outside a field’s list, the step fails, names the row and the field, and writes nothing. A field the collection does not declare is still written. The card you approve the automation on warns about it and names the step that makes it: declare the field in the collection, or remove what makes it.

Read your records

Collections in the console shows your collections, one at a time. Each one says who writes it: each loop that saves records into it, a crawl by the site it fetches, or an automation by its name. An empty one says who will fill it.

New collection asks what the collection is called and what each record holds: each field’s name and its kind (Text, Number, Yes or no, Date, A file, or A row of a collection). Each record gets its own id, so no record replaces another. To name each record by one of its fields instead, such as a customer’s email, open More options: the form and the card then say “A second record with the same email replaces the first.” Review says what is still missing. Your loops and pages add records, and an owner can add one by hand; the collection then says it is written by an owner, by hand on Collections.

  • Definition shows what the collection is: each field with its kind in words and, where it has them, its only values; what the key does to a second record; and which fields are sealed: filled from what a person gave, never by the model, and never read back by a model. It says what each loop fills from its inputs, which loops and pages read the collection, and who may change or remove its rows from a page. A write that does not fit it is refused.
  • Records shows the records, sorted by record id. Each row leads with its fields, then when it was written, then the ids: a field pio generates, such as a generated key, and the record id. When the key is a generated id, that one column is the record id.
  • Add a record… on the Records tab, for an owner of a collection that declares its fields, opens one input per field by its kind, the field key first: a list of its values for a field with only some values, Yes or No, a date, and the records of the other collection for a row, each named by its first text, such as Front desk (r-1). Reactor gives the record its own id, and a file is added through a page’s form, so neither has an input. Add the record saves it; anything refused is said under the field it names. A field key must be filled: it names the record, and one already saved is refused rather than replaced.

To change the definition, press Change fields of the collection to add fields or change a field’s values, or Who may change rows of it. Each change shows its card before you save. To show the records on a page of your own design, write a page.

To serve records to another server, create an API with New API on Schedules, then open it there and press its Mint a key for button, which names the API. See Collections in the API reference.

Outputs

outputs say where a result goes when something happens in a run. A run with no user must declare at least one.

A webhook or an email output names the event that sends it:

  • run.ended: once, when a run ends.
  • run.escalated: when a run, or a task inside it, hands the work to your team.
  • record.written: each time a run saves a record, while the run goes on. Two records saved make two deliveries, each naming its collection and record.

run.started and check.failed are not sent yet, so publishing an output on either is refused.

A webhook

outputs:
  - kind: webhook
    event: run.ended
    url: https://crm.example.com/hooks/pio

When a run ends, the platform POSTs a short JSON body to your url: the event, the loop and its version, the run, and how it ended. The body carries identifiers, never text a model wrote; read the run itself through the run API. Each delivery is signed with the loop’s webhook signing secret, shown on the loop’s Keys tab. Webhooks shows the body, how to check the signature, and the retries.

An email

outputs:
  - kind: email
    event: run.ended
    to: sales@example.com

to is one address. To email several people, add one email output for each: a list in to is refused when you save the loop.

When a run ends, the platform sends an email to to, but only to a verified recipient:

  • An address that belongs to a member of your account is verified already.
  • Any other address gets one confirmation email from plutonium.io when you publish the loop. It receives nothing from the loop until that person confirms. The link in the confirmation is valid for 7 days.
  • Every email carries a one-click unsubscribe link. After an unsubscribe the address gets nothing more, and adding it again needs a new confirmation.

An email for an address that is not verified is held: it is not sent, and it is not sent later either. After the person confirms, the next run’s email goes to them. A held email is not charged, and on Loops its output counts it as not sent and says why. While an address a published loop emails waits on a confirmation, Home names it under Needs you, with how many of the loop’s emails are held.

When you publish, the loop’s page lists each address its email outputs name and what the publish did about it. Afterwards, under each email output, Loops shows where its recipient stands: confirmed, asked, lapsed, never asked, unsubscribed, or bounced. If no confirmation is waiting (it lapsed, none could be sent, or the person unsubscribed), press Send a confirmation email there. A confirmation that is still valid is not sent again.

If the confirmation email bounces, the address does not accept mail. Its recipient reads “the confirmation bounced”, and nothing asks that address again: correct the address in the email output and publish the loop again. An address in a loop published before confirmations existed has no recipient line; publish the loop again to send it a confirmation.

A page’s form can email at each save, without a loop, with the same confirmation for an address that is no member’s: see Email someone when a form saves.

Delivery status

On Loops, each webhook or email output says how its deliveries are doing: how many failed, how many are waiting or retrying, how many emails were held and not sent, and the last error. A delivery that fails never changes how the run ended.