Kelpie Cloud is coming soon. Self-host Kelpie for free today →

← Blog

Release

Kelpie 0.23: agents propose, people decide

A new trust model for everything agents write into your CRM, safer keys out of the box, and background work that survives a restart.

Kelpie 0.23 is out today. It's a big one, even if most of it is the kind of work you won't see on screen. The headline is a new trust model for the things agents and AI write into your CRM. Around that, we've tightened up keys and scopes, moved slow work onto the job queue, and closed a long list of security gaps.

Self-hosting? Skip down to Before you upgrade before you install it. One migration fails if a workspace has two owners, and some API clients will need new scopes.

Why trust is the theme

Kelpie is built for agents. They read the same API your team uses, and they write to it too. That's the whole point.

But it raises a fair question. If an agent can write to your CRM, what stops it writing something nobody agreed to? And what stops a cleverly worded form submission from talking an AI into doing it?

Until now, the honest answer was "the scopes on its key, and not much else". This release changes that.

Agents propose, people decide

Decisions are one of the most important things in Kelpie. They're the commitments your team has made, and agents read them so they don't contradict you. That only works if every Decision is something a person actually agreed to.

So from 0.23, a Decision written by an agent or by AI starts life as a proposal. You'll see it on the record marked "Proposed by an agent" or "Proposed by AI", with a Confirm button next to it. It isn't a commitment until someone on your team clicks Confirm, and only a signed-in person can do that. If you have an integration you trust to make commitments on its own, give its credential the new decisions:commit scope and it can write them directly.

You can always see who wrote what

Notes and Decisions now carry an origin: member, agent, ai or unknown. The server sets it, so a client can't pretend to be a person. It also only moves one way. If an agent edits something a member wrote, the origin drops, and nothing can raise it back up.

AI tasks only do what you let them

Every AI task now says up front what it's allowed to do, using may_create_decision and may_pin. If the model tries anything else, the code throws it away. It doesn't matter how persuasive the prompt was. And a pin can only land on the record the task is running on.

Defending against prompt injection

Your CRM is full of text from the outside world: form submissions, emails, notes pasted from web pages. Sooner or later, some of that text will try to give an AI instructions. Here's what we've done about it:

  • Record content is clearly marked as data in every prompt.
  • Labels are kept to one line, and backticks are escaped, so text can't break out of its slot.
  • Person research now uses two separate model calls. Your CRM data never goes to the call that can search the web.

We've also added npm run eval:injection. It sends the real prompts to a real model, with hostile text planted in a test record, and reports what the model tried and what got through. It's a manual tool rather than part of CI, because it calls a live model and costs a little every time you run it.

Keys and scopes that do less by default

A lot of this release is about making credentials safer out of the box, so a leaked or over-eager key can do less damage.

Deleting is now its own permission. Each resource has a new <resource>:delete scope, and there's a delete:objects preset. Handbook writes are separate too, so write:objects no longer includes handbook:write. Your existing keys are fine. Migration 0065 updates them so they can still do everything they could before.

New API keys start narrow. They get read:objects write:objects unless you ask for more, and full access needs full_access: true. A scoped key also can't create a key more powerful than itself.

Scope checks fail closed. Modules now declare the scopes for their routes with context.routeScopes(...). If a module forgets, scoped keys get a 403 instead of walking straight in. Of the two mistakes, that's the one we'd rather make.

OAuth gets multi-workspace support and a better consent page

An agent can now connect once and work across several of your workspaces. Each MCP tool takes a workspace_id, and a new workspaces_list tool tells the agent which ones it can use.

The consent page is more careful about what it asks for. Sensitive scopes get their own section and start unticked. A scope is only pre-ticked if you've already granted it. And local clients, the ones that redirect to your own machine, don't get anything pre-ticked at all.

Under the hood, redirect URIs now have to match an allowlist, errors before consent show an error page instead of redirecting, and codes and refresh tokens are only used up when an exchange actually succeeds.

Background work that survives a restart

A few things used to run inside the request. That was fine until a deploy or a crash landed at the wrong moment, and the work vanished. In 0.23 they all run on the job queue instead:

  • Person intake. identify and research now answer straight away with a 202 and a run_id. You poll GET /v1/ai/person-intake/runs/:id for the result. Results stick around for an hour, and only whoever started the run can read them. If you call intake from your own code, this one needs a change on your side.
  • Agent tasks. Dispatch runs as a job, with a 60 second timeout and no retries. Each workspace runs at most two at a time.
  • Webhooks. Failed deliveries retry for up to four minutes. A retry keeps the same id, so you can de-duplicate, and gets a fresh signature.
  • Account emails. Verification, password reset, address change and invitation emails are all jobs now.

If you write modules, the job port picks up group, groupConcurrency, attempt, lastAttempt and onDeadLetter. Queue settings are also refreshed every time the service starts.

Better email and safer forms

Email picked up a lot of small fixes that add up to something much more dependable:

  • Invitations now say who invited you and to which workspace.
  • Form emails come from "<your workspace> via Kelpie", so people know who's writing to them.
  • Kelpie checks EMAIL_FROM when it starts, and every email carries Auto-Submitted headers so auto-responders leave it alone.
  • When a mail server permanently rejects a message, Kelpie stops retrying. Forms show it as refused, and invitations show "Email not delivered".
  • SMTP now requires STARTTLS, unless the relay is on the same machine or you set SMTP_REQUIRE_TLS=false. Sends time out instead of hanging, and empty credentials count as unset.
  • The log email provider no longer writes message bodies in production.

The biggest forms change is this one: an anonymous submission can no longer edit a record that already exists. Someone who knows a customer's email address can't overwrite their details, and they can't turn a withdrawn consent back on. Mapped values also pass the same checks as the API now. The auto-reply needs the spam check turned on, and notifications to addresses outside your workspace have a daily cap.

And a long list of smaller fixes

None of these would make a headline on their own, but together they close a lot of doors:

  • Size limits on request bodies, field lengths, MCP batches, and CSV rows and columns.
  • Writes made with a browser session need a matching Origin and a JSON content type, which blocks cross-site requests.
  • Login timing and password resets no longer give away which email addresses have accounts.
  • More rate limits, and IPv6 clients are counted by /64 prefix so they can't dodge them.
  • The SSRF guard now connects to the exact address it checked, which closes off DNS rebinding.
  • CSV exports escape anything a spreadsheet would run as a formula.
  • Imports no longer leak database error text.
  • Images in markdown show as links rather than loading on their own.
  • Members can no longer reach admin pages.
  • A workspace can only have one owner, and the database enforces it.
  • Stored idempotent responses belong to one caller, and anything holding a secret is never stored.
  • Key rotation no longer races with writes happening at the same time.
  • Module routes follow stricter path rules, so a route can't slip past its gate.

Before you upgrade

Most people won't need to do much, but a few things need your attention. Find the section that fits you. This release includes core migrations 0059 to 0066, and ai module migrations 0010 and 0011.

If you run Kelpie

  • Make sure no workspace has two owners, or migration 0066 will fail. This query should come back empty:
    SELECT "workspace_id", count(*)
    FROM "workspace_members"
    WHERE "role" = 'owner'
    GROUP BY "workspace_id"
    HAVING count(*) > 1;
  • Run migration 0065 before the new code starts serving requests.
  • Check that EMAIL_FROM is a single address, or Name <address>.
  • If your SMTP relay doesn't support STARTTLS, set SMTP_REQUIRE_TLS=false.
  • If you run a separate worker, it needs to load the module that provides the agent dispatcher. An API started with --no-worker needs a worker running beside it.
  • Your reverse proxy needs to pass the Host header through.
  • Agent runs the old version left queued won't pick back up. Run those tasks again.
  • In a scaffolded project, run chmod 600 .env, bind Postgres to 127.0.0.1, and set NODE_ENV in production.

If you build on the API

  • Person intake clients need to poll for the run result. It's no longer in the response body.
  • Deletes and handbook writes need the new scopes.
  • Decisions from your agent become proposals, unless its credential has decisions:commit.
  • When an OAuth client reconnects, it has to ask for every scope it wants to keep.
  • Changing an agent's endpoint clears its auth header, unless the same request sends it again.

If you use Kelpie

  • Resetting your password now revokes your personal API keys and OAuth connections. You'll need to reconnect your agents afterwards.

If you write modules

  • Declare your route scopes with routeScopes.
  • Give every router verb call its own path.
  • Use isOAuthActor, because Actor has a new kind, oauth_connection.
  • If you insert into notes or decisions directly, set origin and confirmed_at.
  • ResealPass implementations need to be safe to run twice, and must report changed.

That's 0.23

The full changelog has every detail. If anything goes sideways while you upgrade, open an issue on GitHub and we'll take a look.