Automate is a Rust-based automation server designed to automate common manual tasks and use Todoist to request human involvement when necessary.
It facilitates things like calendar sync, RSS syndication, and the automatic management of GitHub notifications, as well as keeping YNAB stock accounts up to date with live market prices; it also handles webhooks from services like Tailscale, Honeycomb, and Grey (raising a Todoist task when a monitor stays unhealthy, then walking it through recovering and recovered once the monitor comes back).
Install with Homebrew:
brew install sierrasoftworks/tap/automateAutomate is configured via a config.toml file. An example
configuration file can be found at config.example.toml. You can copy
this file to config.toml and modify it to suit your needs.
The admin REST API (under /api/v1) is protected by the admin_acl
access-control filter in [web.auth]. The expression is evaluated for
every request and must return true for access to be granted; it can
reference the request method, path, client_ip, and headers.*.
Access is denied by default, so you must opt in explicitly.
To require single sign-on, add a [web.auth.oidc] section pointing at
an OpenID Connect provider. When configured, the admin SPA runs the
Authorization Code request in a popup: it reads the provider's
authorization endpoint, client id, and scopes from
GET /api/v1/auth/metadata, opens the provider in a popup, and the popup
POSTs the returned code to POST /api/v1/auth/token. The agent performs
the confidential token exchange with its client_secret and returns the
ID token (and a refresh token, if issued) to the SPA. The browser never
holds the client secret. The SPA stores the ID token in sessionStorage
and sends it as an Authorization: Bearer header on every API request;
when the token expires (HTTP 401) it transparently renews it via
POST /api/v1/auth/refresh and retries once, so an active session is
restored without prompting. The token's aud, iss, exp, and nbf
claims are validated, and the remaining claims (e.g. email, groups)
are exposed to the acl filter under the claims. prefix, so you can
write rules such as claims.email == "me@example.com" or
"admins" in claims.groups. Register {origin}/auth/callback as the
provider's redirect_uri. Because the credential is a bearer header
rather than an automatically-attached cookie, there is no CSRF surface
and no CSRF token to manage; signing out simply discards the stored
token. Include offline_access (or your provider's equivalent) in
scopes so a refresh token is issued and sessions can renew silently.
If you run behind a reverse proxy and want absolute URLs to honour the
forwarded scheme/host, set web.trust_proxy = true; only do so when the
proxy is trusted, since these headers can otherwise be spoofed. The same flag
governs whether X-Forwarded-For is trusted when the admin acl evaluates
client_ip.
Records are only divided between accounts once you ask for it, with
multi_tenant = true under [web.auth]. Until then everything belongs to
the installation's own account, whether or not people sign in — an
installation that has been running with an identity provider already has
its workflows and connections in one place, and reading the signed-in
identity as an account name would take all of them away from the people
using them without saying so. Signing in still decides who may do what;
it just does not decide where things are kept.
With it enabled, every stored record belongs to an account, named by
the OIDC username claim (preferred_username by default; set username_claim under
[web.auth.oidc] to use a different one). Accounts are isolated at the
storage layer rather than by convention: the handle a job or request
handler holds is scoped to one account and has no method that names
another, so reaching across accounts is not something code can express.
Two account names are reserved for the installation itself and cannot be
claimed by a person: !system, which holds the user registry and the
caches that map an inbound webhook to its owner, and !local.
[web.auth] provides two independent access gates:
user_acldecides who may sign in at all.admin_acldecides who may administer the installation, which includes acting as another user.
Both are evaluated on every request against the same filter surface, so a change takes effect immediately. Each denies access when omitted.
An administrator can act as another user by sending
X-Impersonate-User: {username}. Permission checks and audit entries
continue to name the administrator, so administrator status never follows
the impersonated account and any change made this way is recorded against
the account it affected.
The admin UI drives this from its Accounts page (and from the Act as… menu on the user chip): choosing somebody sets the header on every subsequent request and puts a banner across the top of the shell naming whose records are on screen. The choice lives in the tab's session, so opening a new one lands back in your own account.
Alongside the people who have signed in, that list includes the
installation's own account (!local by default, or local_user where
one is configured). Nobody can sign into it, but it owns everything
configured before multi_tenant was switched on, so acting as it is the
only way back to those records. The !system tenant is deliberately not
offered: it holds the user registry and the webhook indexes, which are
the agent's own bookkeeping rather than anybody's records.
Credentials for external services are held as connections: one linked account at one service, owned by the user who linked it. A workflow names the connection it publishes through, so two people — or one person with a personal and a work Todoist account — never share a credential.
Manage them under /api/v1/connections. Services authorised through
OAuth are linked by the setup wizard, which obtains the credential
itself; services that issue a token you paste in are created directly.
Naming a connection on a workflow is optional. With one account linked to a service it is used automatically; with several, a workflow that does not say which is reported as ambiguous rather than guessed at, since picking one would file your tasks in the wrong account.
If you had [connections.todoist] in your configuration, it is imported
once on start-up into the account a single-user installation runs as, so
your workflows keep publishing. Once that has happened you can delete the
section. The import never overwrites a connection you have since replaced.
Register a Todoist app at https://app.todoist.com/app_console/, point its
OAuth redirect URL at https://your-host/integrations/todoist/setup/callback,
and put its credentials in [connections.todoist.app]. Each person then
connects their own Todoist account from the connections page, and the agent
acts as them rather than through one shared token.
To receive events, set the app's webhook callback URL to
https://your-host/webhooks/todoist — Todoist requires HTTPS with no port —
and configure client_secret. Todoist uses the Client Secret as the webhook
HMAC key; the Verification Token shown in the App Management Console is not
used for webhook signatures. The Todoist workflow type can then react to
items being completed, comments being added, projects being archived, and so
on, as they happen. Deliveries are routed by the Todoist account they name, so
each person's events only reach their own workflows.
Access tokens issued to a new Todoist app last an hour and are renewed on demand, immediately before the token is used, rather than on a schedule.
API tokens, OAuth refresh tokens and webhook signing secrets are encrypted with AES-256-GCM before being stored. Each value is bound to the record holding it, so a ciphertext moved to another account's record will not decrypt.
If you do not set secret_key under [web.auth], a key is generated on
first run into <database>.key with owner-only permissions. Back that
file up — without it, stored credentials cannot be recovered. To manage
the key yourself, generate one with openssl rand -base64 32 and set
secret_key. When rotating, move the old key into
previous_secret_keys and leave it there until every record that used it
has been rewritten.
Some workflows act on third-party accounts (for example Spotify) that you link by walking through an OAuth flow. The agent drives the confidential exchange server-side and stores the resulting refresh token.
Every external service you connect — an OAuth2 provider, the GitHub App — goes
through the same setup wizard, so what follows applies to all of them. The agent
lists what is configured at GET /api/v1/integrations.
Admin-gated integrations (the default) are launched from the Connect dropdown
in the admin area's toolbar: the SPA calls the bearer-authenticated
POST /api/v1/integrations/<id>/setup/start, which returns a provider
authorization URL the SPA opens in a popup. The provider redirects back to the
agent's server-rendered callback, which records the connection. An integration
can instead opt into self-service access by setting its own acl — under
[oauth2.<provider>] or [connections.github.app] — evaluated just like the
admin ACL, so acl = 'true' lets anyone connect their own account without
signing in. A self-service integration can also be linked directly at
/integrations/<id>/setup as a top-level navigation (no admin bearer required);
an admin-gated one opened that way is directed to the admin area instead, except
when OIDC is disabled, in which case the admin ACL is evaluated on the request
directly. Each flow is bound to the browser that began it by a single-use
state value (held in a transient cookie scoped to the integration's callback
path) to prevent login CSRF.
The accounts connected to an integration are listed at
GET /api/v1/integrations/<id>/connections and shown in the admin area, where
each can be severed. Disconnecting is not undoable from Automate: for GitHub it
uninstalls the App from the account, and for an OAuth2 provider it discards the
stored credential.
OAuth2 callbacks keep their original /oauth/<provider>/callback path, since the
redirect URI is registered with each provider and cannot be moved without
reconfiguring the provider's application.
The project is a Cargo workspace split into three crates, mirroring the grey project:
agent/— the backend automation server (actix-web). It also serves the compiled UI as static assets (embedded at build time fromui/dist).api/— pure serde data-transfer types shared by the agent and the UI so the REST contract cannot drift between them.ui/— a Yew client-side single-page app, compiled to WebAssembly with Trunk. It talks to the agent exclusively over the/api/v1REST API.
The UI is a pure client-side app, so it can be developed independently of a running agent. Install the toolchain once:
rustup target add wasm32-unknown-unknown
cargo install trunkThen, from the ui/ directory, start the dev server with live reload:
trunk servetrunk serve proxies /api/v1, /integrations and /oauth to a locally
running agent (see ui/Trunk.toml). To preview the interface without a backend,
append ?demo to the URL — the app then renders baked-in sample data.
To produce the production bundle that the agent embeds:
trunk build --releaseTo run Automate, ensure you have Rust installed and then execute:
# Build the UI bundle first so the agent can embed it.
(cd ui && trunk build --release)
# Then build and run the agent.
cargo run --release -p automate