Skip to content

feat: custom actions - #4576

Draft
Julusian wants to merge 26 commits into
feat/input-field-editorfrom
feat/custom-actions
Draft

Julusian wants to merge 26 commits into
feat/input-field-editorfrom
feat/custom-actions

Conversation

@Julusian

Copy link
Copy Markdown
Member

Closes #3149

This allows building your own reusable actions. These custom actions contain other actions with their own local variables. There is a form to define the input fields they have, and they have outputs which can be written back to other variables.

The aim with this is to allow for building reusable chunks of logic. I am hoping to follow this with 'custom feedbacks', and 'custom presets'.

These custom actions are their own control type, giving them the basic lifecycle (mostly for editor purposes). Anywhere which uses the custom action takes a copy of the actions/variables inside of the custom action, as the variance in options means running a new instance.
Internally, this effectively adds them as volatile children inside the entity tree.

When the custom action gets executed, we create another copy of the child entities, as we need to freeze the input options, but keep the feedbacks updating. Without this a while loop waiting for a feedback to become true would never resolve. And once executing we don't want to react to changes to the custom action inputs, as that would cause a skew in what it is executing. that feedback could end up stuck waiting for the wrong thing to change.

This has heaped on complexity to handle this, especially to do so without flooding modules with constant creation and deletion of new entities which only live for the duration of the run. The entitymanager is now able to deduplicate these entities within a control, so that it only has to report to modules the ones where the options diverge.

These survive through export+import, probably not very well through a page import.

I suspect placement in the sidebar is incorrect once the feedbacks and presets exist, but its ok for now.

image image image image image image

claude and others added 26 commits October 29, 2026 23:29
Add the `custom-action:<id>` control type and its model: a name, description,
user defined inputs, a list of actions, local variables and an optional result
variable. Like the other controls, the model is a zod schema, which is part of
`SomeControlModelSchema`. Each custom action will be offered as an action of
the virtual `__custom-actions__` connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
…actions

A custom action is stored as a `custom-action:<id>` control holding its
inputs, actions and local variables, with collections like triggers and
expression variables. Each one is published as an action of the virtual
`__custom-actions__` connection, whose options are its inputs.

Running one of those actions parses its options in the context of the caller
and then runs the actions of the custom action, waiting for them to finish.
The run has its own frame (`RunActionExtras.frame`), so that concurrent runs
can't interfere with each other:

- `$(options:<id>)` are the inputs given to the run, falling back to the
  default of an input which is missing or invalid
- `$(local:x)` are the local variables of the custom action, and setting one
  only changes it for the run. Computed variables are evaluated for the run
- `$(this:*)` are those of the caller, and the caller's abort signal stops it
- custom actions calling each other are limited to 16 deep

The result of the action can be one of its local variables.

The inputs must be fields a user can author (`AuthorableInputFieldSchema`), with
an expression for their visibility and an id usable in a variable, for a custom
action to be loaded or imported. One which isn't is repaired like an invalid
entity: it is replaced by a static text field, which keeps its id and shows the
original, rather than losing the whole custom action.

Module feedbacks inside a custom action are subscribed with the defaults of
its inputs, so can't see the values of a run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
Add a Custom Actions page, with collections, where each custom action has its
name, description, result variable, notes, a test run, and tabs for its
actions, inputs (using the reusable input field definitions editor) and local
variables. The editor warns about connection feedbacks which reference the
values of a run, as they can't see them.

Custom actions are offered in the action picker under a "Custom actions"
group, grouped by their collections, and their inputs are suggested as
`$(options:x)` variables when editing a custom action. The virtual
connections now have a readable label, so a custom action shows as
"Custom action: <name>".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
An action calling a custom action which was added before an input existed
has no value for it. That was parsed as an empty value, rather than the
default of the input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
Custom actions and their collections are now a section of a full export,
import and reset, like expression variables.

They are new optional sections of version 17 of the export format, which is a
compatible change, and each custom action in an import is repaired like a
stored control.

When importing, the actions of every connection provided by Companion itself
are now kept, not just the internal ones, so that actions calling a custom
action are no longer dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
A custom action can no longer contain an action which runs itself: it isn't
offered in the action picker of its own editor, and adding one is rejected.
One saved before this is skipped when run.

Custom actions calling each other are still allowed, limited to 16 deep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
Entities parsed their options with the variables of their control, looked up
by control id, in both the entity manager and the internal module. Instead
each entity list now has a scope (its parser and special expression manager),
which its entities and their children use. The lists of a control use the
control's own scope, so nothing changes yet.

This lets a list provide a different scope for its entities, such as the
inputs and local variables of a run of a custom action, while still belonging
to the same control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
Entities in the same control which parse to the same options now share a
single subscription with the module. The subscription id is derived from
the control, entity type, definition, parsed options and (for older
modules) the inversion, so no entity owns it and nothing moves as members
come and go.

- An entity joins a subscription when it is parsed, moving to another when
  its options change. The last member leaving deletes the subscription.
- Feedback values reported for a subscription are passed to every member,
  and an entity joining an existing subscription gets its last value
  immediately.
- Executing an action sends the id of its subscription, so the module sees
  the id it was subscribed with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
An entity can now hold child groups which exist only while Companion is
running. Each has its own scope for parsing options, and is never
persisted, exported or shown in the ui.

They take part in everything which runs entities (subscribing, cleanup,
feedback values, special expressions, finding an entity to upgrade, and
tracking entities when a connection starts), but not in anything which
edits or describes the stored entities.

This is for the copy of a custom action held by each action which calls it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
…stom action

Every action which calls a custom action now holds a copy of the custom
action's actions and local variables, in transient child groups which are
never persisted. The copy is subscribed in the control holding the action,
with:

- the `this:*` and `page:*` variables of that control
- the inputs given by the action as `$(options:*)`, re-parsed whenever the
  options of the action or the variables they use change
- the local variables of the copy, at their startup values

So connection feedbacks and actions in a custom action can now depend on
its inputs, and have their values ready before it is run. Identical
subscriptions are shared by the merging in the entity manager.

The copies are rebuilt when the custom action changes, and are built at
startup once every custom action is loaded. A copy which would loop back
to a custom action it is inside, or nest more than 16 deep, is not built.

Runs still use the actions of the custom action itself; using the copy for
a run comes next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
Now that a subscription id covers everything sent to the module, an entity
which re-parses to the same options doesn't need sending again. It is only
resent when something outside the id has changed: the image size of a
feedback, or the definitions of the module.

This avoids sending the module unchanged subscriptions every time a
variable used by an entity changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
Running a custom action now builds a further copy of it for just that run,
from the copy held by the calling action:

- its inputs are parsed once, when the run starts, so nothing changes
  underneath the run
- its entities see the local variables of the run, and are re-parsed when
  one is set, so connection feedbacks can depend on them
- when it sees the same inputs as the copy held by the caller, it starts
  with that copy's feedback, isInverted and storeResult values (including
  those of any custom actions it calls), so it doesn't wait on the module
- it is removed when the run ends

A loop of custom actions is no longer run until it is 16 deep: the copy
which would loop is never built, so that call does nothing. Running from the
editor still runs the custom action's own actions with its default inputs.

The controls controller and action runner now share the manager of the
copies, which is created by the registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
…with a real module

Connection feedbacks in a custom action now see the inputs given by each
caller, and the local variables of each run, so the editor no longer warns
about them.

Adds integration tests: a connection feedback, as a local variable of a
custom action, gives each caller the answer for its own input; and changing
an input part way through a run doesn't affect that run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
Each tab now starts with the same short description, with the test run
button beside it on the actions tab rather than on a row of its own. The
editors in the tabs no longer repeat the name of the tab as a heading,
like the tabs of a trigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
…bles

The result of a custom action is one of its local variables, so with none
there is nothing to choose. The field is disabled, and its tooltip says to
add a local variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
The input field editor looked and behaved like nothing else in the app.
It now matches the actions and events of a control:

- each input is a row of the entity list, with a drag handle to reorder
  it, its type as a badge, its label, and buttons to remove and collapse it
- an input is added by choosing its type from a "+ Add input" dropdown at
  the end of the list, so its type is no longer changed after it is added
- the optional properties are shown with "More options", rather than in an
  accordion

Moving an input now moves it to an index, rather than up or down one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
…ions

Each tab of the custom action editor now starts with the same heading as
the actions of a control: the name of the list, expand and collapse all,
and any buttons, with a description of the list below it. The test run
button is in the heading of the actions.

Removing an input now asks for confirmation, like removing an action or
event.

The local variables editor takes a description of what the variables are
for, shown above its usual hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MuPpHgo3vKCpHZieedYM6i
…m inputs

An input of a custom action is now always shown and always allows an
expression. "Visible when" and "Allow expressions" were more complexity
than they are worth for now, so they are no longer offered or stored.

"More options" now sits above the options it shows, rather than below
them, so it reads as the heading of that section.
A disabled field shows no tooltip on hover, so with no local variables the
result now says below it to add one.
The value format of a colour input is no longer offered, as a number is
not what a user would expect to use. A new colour input defaults to white.
The page, custom variable and connection inputs are no longer offered, as
they are more than custom actions need for now.

A colour input no longer stores that its value is a CSS string, as it
always is. `toCompanionInputField` fills that in where the input is used as
a field.
A custom action now has outputs, rather than a single result. Each output
is one of its local variables, and each action which calls the custom
action chooses where it is written: a local variable of its control, a
page variable or a custom variable, with the same picker as the result of
an action.

An output is written every time a run sets its local variable, so the
caller can see the progress of a run, such as a counter in a loop shown on
a button. Its final value is written once the run is done, which covers
outputs computed from other variables.

The outputs are edited in a new Outputs tab, and the result field of a
custom action is gone.
…ange

Subscription ids were derived from the parsed options, so any change to an
entity's options was sent to the module as a delete and a new subscription.
That calls unsubscribe and subscribe instead of the module seeing an update
with previousOptions, and lets it no longer decide whether a change matters.

Subscription ids are now random, with a separate index from the key (control,
type, definition, parsed options) to the subscription for finding
identical entities. Entities whose options changed are placed once a whole run
has been processed:
- joining an existing subscription for their new options first
- otherwise reusing a subscription they emptied, updating it in place, so a
  sole member or all members changing together keeps the same id
- otherwise creating a new subscription

Update payloads are now split into batches when sent, as subscriptions are
placed after the loop that used to split them.

Inversion is no longer part of the key. Companion applies it to the value of
each entity, so feedbacks which differ only in inversion share a subscription.
…authored input

The inputs of a custom action are now just authored input fields, as the
shared schema already checks their id.
@github-project-automation github-project-automation Bot moved this to In Progress in Companion Plan Oct 29, 2026
@Julusian Julusian added this to the v5.2 milestone Oct 29, 2026
@coderabbitai

coderabbitai Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants