0.459 (18 Jun 2026)

18 June, 2026

This release adds conditional subscriber triggers with filter expressions on @on, experimental durable agent primitives for building AI workflows in flows, and external access for flows via public runs and shareable signed links.

Conditional Subscriber Triggers

The @on attribute now accepts an optional third argument: a boolean expression evaluated before an event is published. Events that don't match the filter are dropped at the source — they never reach SQS and never invoke the subscriber function. For high-write models where only a subset of events matter, this eliminates wasted traffic and compute.

model Order {
    fields {
        status OrderStatus
        total Number
    }
 
    @on([create], processNewOrder, order.status == OrderStatus.New)
    @on([create, update], flagBigOrder, order.total > 1000)
}

Previously you'd have to check conditions inside the subscriber and return early. Now the schema handles it:

// Only fire when status just changed to Shipped
@on([update], onShipped, order.status == OrderStatus.Shipped && previous.status != OrderStatus.Shipped)
 
// Only when the status actually changed
@on([update], trackStatusChange, order.status != previous.status)

The previous keyword gives you access to the row's prior state for update and delete events, enabling transition-based filters. You can also use ctx in filter expressions, just like in permission rules:

@on([create], auditAuthenticated, ctx.isAuthenticated)
@on([delete], guard, ctx.env.STAGE == "production")

Registering the same subscriber with different filters gives you OR semantics — the event publishes if any filter matches. Filters always evaluate against the audit snapshot (the same data the subscriber receives), and they fail open: if a filter can't be evaluated, the event is published as if unfiltered.

Experimental Agent Primitives

This release introduces experimental durable AI agent primitives in the functions runtime, built on the existing flows replay engine. Every LLM call and tool invocation is a checkpointed flow step — you get replay, retries, and human-in-the-loop approval gates for free.

All new APIs are namespaced under experimental to signal their pre-release status.

One-Shot LLM Calls

experimental.LlmFlowStep makes a single typed LLM call as a checkpointed step. Use it for classification, extraction, or drafting where you don't need a full agent loop:

import { TriageTicket, experimental, models, z } from "@teamkeel/sdk";
 
export default TriageTicket({}, async (ctx, inputs) => {
  const ticket = await ctx.step("load ticket", async () => {
    const t = await models.ticket.findOne({ id: inputs.ticketId });
    return { id: t!.id, body: t!.body };
  });
 
  const triage = await experimental.LlmFlowStep(ctx, "classify", {
    model: "anthropic/claude-haiku-4-5",
    prompt: `Classify this ticket: ${ticket.body}`,
    result: z.object({ category: z.enum(["billing", "bug", "other"]) }),
  });
 
  await ctx.step("save category", async () => {
    await models.ticket.update({ id: ticket.id }, { category: triage.category });
  });
});

Multi-Turn Agent Loops

experimental.defineAgent and experimental.defineTool give you a full agent loop with tool use. Each turn and tool call is a checkpointed flow step, so sessions survive crashes and can include human approval gates:

import { ChaseTicket, experimental, models, z } from "@teamkeel/sdk";
 
const sendReply = experimental.defineTool({
  name: "send_reply",
  description: "Send a reply to the customer",
  parameters: z.object({ reply: z.string() }),
  approval: true,
  execute: async ({ reply }) => {
    const t = await models.ticket.findOne({ id: inputs.ticketId });
    await models.ticket.update({ id: t!.id }, { reply });
    return { sent: true };
  },
});
 
const chaser = experimental.defineAgent({
  name: "TicketChaser",
  model: "anthropic/claude-haiku-4-5",
  instructions: "You chase up support tickets. Look up the ticket, then send a short, polite reply.",
  tools: [lookupTicket, sendReply],
  prompt: ({ inputs }) => `Chase up ticket ${inputs.ticketId}.`,
  result: z.object({ replied: z.boolean(), summary: z.string() }),
});
 
export default ChaseTicket({}, async (ctx, inputs) => {
  const outcome = await chaser.run(ctx, { ticketId: inputs.ticketId });
  await ctx.step("record outcome", () => outcome);
});

Tools with approval: true pause execution at a ctx.ui.page approval step, letting a human review before the tool runs.

@externalAccess for Flows

Flows can now be exposed to external users with the new @externalAccess attribute. Setting public: true makes the flow's run action fully public with no permission check required:

flow CheckoutFlow {
    inputs {
        orderId ID
    }
 
    @externalAccess(public: true)
}

Without public: true, the attribute marks the flow as shareable — you can mint signed links that let unauthenticated users open and interact with a specific flow run. Links support reusable or single-use modes and optional expiry:

const link = await flows.checkoutFlow.signedLink({
  inputs: { orderId: "order_123" },
  expiresAt: new Date("2026-07-01"),
  reusable: true,
});

Shareable links now point directly at the Keel console for a better user experience.

Fixes and Improvements

  • Flow step replay fidelity — fixed a bug where nested JSONB keys were incorrectly camelCased on replay, causing first execution and replay to return different values from flow steps.
  • NonRetriableError messagesNonRetriableError now surfaces the original error message instead of wrapping it in a generic "exhausted step retries" message, making flow errors easier to debug.
  • Reserved Keel-prefixed names — model, task, and enum names starting with Keel followed by another word (e.g. KeelAudit) are now reserved by the schema validator. Single-word names like Keel remain valid.

For a full list of fixes and improvements, check out our GitHub releases page (opens in a new tab).

For any issues or feedback, please contact us at help@keel.so.

Thank you for using Keel!