Skip to content

feat: add trigger primitive for configuring and receiving external events#2146

Merged
danielkov merged 2 commits intomainfrom
daniel/age-1765-feat-add-triggers
Apr 14, 2026
Merged

feat: add trigger primitive for configuring and receiving external events#2146
danielkov merged 2 commits intomainfrom
daniel/age-1765-feat-add-triggers

Conversation

@danielkov
Copy link
Copy Markdown
Contributor

@danielkov danielkov commented Apr 9, 2026

Why

Gram needs a first-class way to react to external events and turn them into internal work.

This PR introduces triggers as the primitive for describing how Gram receives events from external systems and how those events should be routed into Gram.

The immediate goal is to unblock assistant-style products that need to respond to external systems like Slack or scheduled jobs, while giving us a project-scoped resource we can observe, configure, and evolve later.

What This Enables

  • A project can define reusable trigger instances with stable IDs.
  • Gram can accept external events through known trigger types and normalize them into internal trigger events.
  • Trigger instances can be configured through a platform tool or the dashboard UI.
  • Each trigger instance can be connected to an environment and a stub runnable target, which lets us validate the configuration model before introducing the full "thing to run" abstraction.
  • Trigger delivery outcomes are observable via the Logs page, which gives us a starting point for debugging and support.

Correlation IDs

Triggers use correlation IDs to group related events for downstream routing:

  • Cron — Correlation ID is the trigger instance ID. Every firing of the same cron trigger shares the same correlation ID.
  • Slack — Correlation ID is derived from channel:thread_ts, falling back to just channel, then team_id. All messages in the same Slack thread share a correlation ID, enabling thread-aware routing to assistant conversations.

Trigger Config & Filtering

Each trigger definition owns its filtering logic through the Config.Filter(event any) (bool, error) interface:

  • SlackFilter handles both event-type matching (user selects which Slack event types to receive from the full Events API catalogue) and optional CEL expression evaluation. CEL programs are compiled once during DecodeConfig so parse errors surface at creation time.
  • CronFilter always returns true. Cron config only has schedule (cron expression).

Environment is only required when the trigger definition declares required env vars (e.g. Slack needs SLACK_SIGNING_SECRET). Cron triggers work without an environment.

Example

A typical Slack trigger configured through platform_configure_trigger might look like:

{
  "definition_slug": "slack",
  "name": "support-thread-replies",
  "environment_slug": "prod",
  "target_kind": "assistant",
  "target_ref": "assistant:support",
  "target_display": "Support Assistant",
  "config": {
    "event_types": ["message"],
    "filter": "event.channel_id == \"C12345678\" && event.thread_id == \"1712685400.123456\""
  }
}

The trigger definition handles Slack-specific event ingestion, while the instance-level CEL filter decides whether a normalized Slack event should be sent onward. That gives us one reusable Slack trigger type with per-instance routing rules instead of hard-coding behavior for each integration.

Screen.Recording.2026-04-09.at.21.11.52.mov

Cron trigger demo

Screen.Recording.2026-04-11.at.19.14.32.mov

Slack trigger demo

Scope Of This PR

  • Adds the trigger resource and persistence model.
  • Adds initial trigger types for Slack (all Events API event types) and Cron.
  • Adds the API and generated SDK surface for managing triggers.
  • Adds platform-tool-based configuration for trigger instances.
  • Adds trigger ingestion/runtime plumbing and trigger delivery logging.
  • Adds a minimal dashboard UI for listing, creating, editing, and deleting triggers.
  • Adds deep-linking from triggers table to the Logs page filtered by trigger instance ID.
  • Adds trigger log rows to the Logs page with proper delivery status display.

Deliberate Non-Goals

  • No final runnable/execution model yet beyond stub target fields.
  • No programmatic envelope-based trigger creation flow yet.
  • No execution history model beyond delivery logs.
  • No assistant dispatcher implementation yet — triggers pipeline works up to dispatch.

@danielkov danielkov requested a review from a team as a code owner April 9, 2026 13:57
@linear
Copy link
Copy Markdown

linear bot commented Apr 9, 2026

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 9, 2026

🦋 Changeset detected

Latest commit: 71f72f0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
dashboard Minor
server Minor
@gram/client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@danielkov danielkov changed the title Add trigger primitive and platform trigger config tool feat: trigger primitive and platform trigger config tool Apr 9, 2026
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment Apr 14, 2026 11:38am

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

atlas migrate lint on server/migrations

Status Step Result
No migration files detected  
ERD and visual diff generated View Visualization
No issues found View Report
Read the full linting report on Atlas Cloud

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

atlas migrate lint on server/clickhouse/migrations

Status Step Result
No migration files detected  
ERD and visual diff generated View Visualization
No issues found View Report
Read the full linting report on Atlas Cloud

@danielkov danielkov changed the title feat: trigger primitive and platform trigger config tool Add trigger primitive and generic trigger configuration Apr 9, 2026
@danielkov danielkov changed the title Add trigger primitive and generic trigger configuration Add trigger primitive for configuring and receiving external events Apr 9, 2026
@blacksmith-sh

This comment has been minimized.

@simplesagar simplesagar changed the title Add trigger primitive for configuring and receiving external events feat: add trigger primitive for configuring and receiving external events Apr 9, 2026
@danielkov danielkov requested a review from a team as a code owner April 9, 2026 16:06
@github-actions github-actions bot added the preview Spawn a preview environment label Apr 9, 2026
@speakeasybot
Copy link
Copy Markdown
Collaborator

speakeasybot commented Apr 9, 2026

🚀 Preview Environment (PR #2146)

Preview URL: https://pr-2146.dev.getgram.ai

Component Status Details Updated (UTC)
✅ Database Ready Existing database reused 2026-04-14 11:43:51.
✅ Images Available Container images ready 2026-04-14 11:43:34.

Gram Preview Bot

for (const log of logs) {
const traceId = log.traceId;
if (!traceId) continue;
const traceId = log.traceId || log.id;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

change too broad, check if needed at all, if needed - check eventSource === "trigger"

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment on lines +367 to +370
result, err := s.runtime.ProcessWebhook(ctx, triggerID, body, r.Header)
if err != nil {
return oops.E(oops.CodeUnexpected, err, "process trigger webhook").Log(ctx, s.logger)
}
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 ProcessWebhook error propagation returns 500 to external callers like Slack

When ProcessEvent fails inside ProcessWebhook (server/internal/triggers/runtime.go:177), the error propagates to HandleWebhook (impl.go:368-369) which returns a 500 HTTP response. For Slack, this means the event delivery is treated as failed, triggering retries and potentially disabling the webhook URL after repeated failures. This is especially problematic in combination with the missing dispatchers (BUG-0001), but even after that is fixed, any transient error in filter evaluation or enqueueing will cause the same behavior. Consider returning 200 to the webhook caller and handling failures asynchronously (e.g., logging the error and emitting a delivery failure telemetry event without propagating the error to the HTTP response).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrong, our boundary should be durable async hand-off, returning 500 before that is correct

devin-ai-integration[bot]

This comment was marked as resolved.

@danielkov danielkov changed the base branch from main to daniel/age-1765-feat-add-triggers-migrations April 9, 2026 20:26
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment on lines +80 to +83
o11y.AttachHandler(mux, "POST", "/rpc/triggers/{id}/webhook", func(w http.ResponseWriter, r *http.Request) {
oops.ErrHandle(service.logger, service.HandleWebhook).ServeHTTP(w, r)
})
}
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Webhook endpoint is unauthenticated by design but lacks rate limiting

The webhook endpoint at server/internal/triggers/impl.go:80 is mounted outside the Goa auth middleware chain using o11y.AttachHandler directly. This is intentional — external services (e.g., Slack) need to POST to it without Gram auth. Security is handled at the definition level (e.g., Slack HMAC signature verification in definitions.go:513-538). The GetTriggerInstanceByIDPublic query at server/internal/triggers/repo/queries.sql:40-44 has no project_id filter, only checking the trigger ID and soft-delete status, which is correct for this public endpoint. However, the endpoint has no rate limiting, which could be a concern for abuse.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

devin-ai-integration[bot]

This comment was marked as resolved.

Comment on lines +95 to +100
logger *slog.Logger,
db *pgxpool.Pool,
temporalEnv *tenv.Environment,
envLoader EnvironmentLoader,
deliveryLogger DeliveryLogger,
serverURL *url.URL,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of these should be nil under any circumstances and yet the methods below have defensive checks for that. We should be spreading nil values down the stack. There are suitable values for all of these dependencies for local, test, production.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 14 additional findings in Devin Review.

Open in Devin Review

envEntries,
&triggerDeliveryLogger{telemetry: telemetry},
serverURL,
bgtriggers.NewNoopDispatcher(logger),
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 No assistant dispatcher registered — triggers with target_kind='assistant' will fail at dispatch time

In server/cmd/gram/triggers.go:95, the newTriggersApp function only registers a NoopDispatcher. The ValidateTargetKind function at server/internal/background/triggers/app.go:551 accepts both assistant and noop, and the UI/API allow creating triggers with target_kind=assistant. However, when such a trigger fires, App.Dispatch at server/internal/background/triggers/app.go:445 will fail with "trigger dispatcher for target kind \"assistant\" is not configured". The trigger instance gets created successfully but will always fail at runtime. This might be intentional for a staged rollout, but users can currently configure something that will never work.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, Devin, by design...

targetKind,
targetRef,
targetDisplay,
...(environmentId ? { environmentId } : {}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Dashboard update form cannot clear environmentId

In client/dashboard/src/pages/triggers/Triggers.tsx:526, the update handler uses ...(environmentId ? { environmentId } : {}) to conditionally include the environment ID. Because empty string is falsy in JavaScript, if a user clears the environment selection, environmentId will be "" and will be omitted from the request. On the server side at server/internal/triggers/impl.go:190-196, when payload.EnvironmentID is nil, the existing environment ID is preserved. This means once an environment is associated with a trigger, it cannot be removed through the update UI. This may be intentional if environments are always required for the associated trigger definitions, but it's worth verifying.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Open in Devin Review

mux.Use(middleware.CORSMiddleware(c.String("environment"), c.String("server-url"), chatSessionsManager))
mux.Use(customdomains.Middleware(logger, db, c.String("environment"), serverURL))
mux.Use(middleware.SessionMiddleware)
mux.Use(middleware.AdminOverrideMiddleware)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 RBACOverrideMiddleware accidentally removed from server middleware chain

The mux.Use(middleware.RBACOverrideMiddleware(c.String("environment"))) line was deleted from the HTTP middleware chain in server/cmd/gram/start.go. This middleware (defined at server/internal/middleware/rbac_override.go:18) reads the X-Gram-Scope-Override header in local development environments and stores structured RBAC overrides on the request context. Its removal is unrelated to the triggers feature—it appears to be an accidental casualty of the import reorganization in this file. Without it, local-dev RBAC scope override testing via the header is silently broken.

Suggested change
mux.Use(middleware.AdminOverrideMiddleware)
mux.Use(middleware.AdminOverrideMiddleware)
mux.Use(middleware.RBACOverrideMiddleware(c.String("environment")))
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

🐛 1 issue in files not directly in the diff

🐛 Soft-delete query sets deleted_at but not deleted = TRUE, so "deleted" triggers remain visible (server/internal/triggers/repo/queries.sql:75-83)

The DeleteTriggerInstance SQL query at server/internal/triggers/repo/queries.sql:75-83 sets deleted_at = clock_timestamp() and updated_at = clock_timestamp(), but never sets deleted = TRUE. All read queries (ListTriggerInstances, GetTriggerInstanceByID, GetTriggerInstanceByIDPublic) filter on deleted IS FALSE. Since the deleted boolean column is never flipped to TRUE, the soft-deleted trigger continues to appear in list results, webhook processing continues to find the instance via GetTriggerInstanceByIDPublic, and the trigger remains operational. The App.Delete method in server/internal/background/triggers/app.go:238-251 also attempts to clean up the Temporal schedule after the DB delete, but because the trigger isn't actually marked as deleted, subsequent operations on it will still succeed.

View 16 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 17 additional findings in Devin Review.

Open in Devin Review

switch {
case errors.Is(err, bgtriggers.ErrBadRequest):
code = oops.CodeBadRequest
case errors.Is(err, sql.ErrNoRows):
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 toTriggerError uses sql.ErrNoRows instead of pgx.ErrNoRows, causing 500s instead of 404s

The toTriggerError function checks errors.Is(err, sql.ErrNoRows) to detect not-found errors, but the trigger repo is generated by sqlc with sql_package: "pgx/v5" (server/database/sqlc.yaml:204), so database queries return pgx.ErrNoRows, not sql.ErrNoRows. These are distinct sentinel errors (pgx.ErrNoRows = errors.New("no rows in result set") vs sql.ErrNoRows = errors.New("sql: no rows in result set")), so the errors.Is check never matches. This means all "trigger not found" errors (e.g., get/update/delete with a non-existent ID) return HTTP 500 instead of 404. The codebase's newer services like toolsets correctly use pgx.ErrNoRows (see server/internal/toolsets/impl.go:513).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

triggerrepo "github.com/speakeasy-api/gram/server/internal/triggers/repo"
)

type triggerDeliveryLogger struct {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be moved into server/internal/background/triggers now since we refactored the telemetry logger.

})
}

func newTriggersApp(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this can be moved back into deps.go

Add the trigger runtime, API surface, generated SDK updates, and dashboard UI for managing triggers. This also includes the follow-up fixes and review updates that landed in the trigger branch before rebasing onto main.
Copy link
Copy Markdown
Contributor

@disintegrator disintegrator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely done. Interfaces and codebase structure are solid. The explicit separation of authenticating and handling of webhooks makes the handling of auth explicit which is great to see.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 18 additional findings in Devin Review.

Open in Devin Review

Comment on lines +384 to +393
func toTriggerError(ctx context.Context, logger *slog.Logger, err error, message string) error {
code := oops.CodeUnexpected
switch {
case errors.Is(err, bgtriggers.ErrBadRequest):
code = oops.CodeBadRequest
case errors.Is(err, sql.ErrNoRows):
code = oops.CodeNotFound
}
return oops.E(code, err, "%s", message).Log(ctx, logger)
}
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Webhook authentication errors return HTTP 500 instead of 401/403

When AuthenticateWebhook fails (e.g., Slack signature mismatch), ProcessWebhook wraps the error with fmt.Errorf("authenticate webhook: %w", err). In impl.go:toTriggerError, this doesn't match ErrBadRequest or sql.ErrNoRows, so it falls through to oops.CodeUnexpected (HTTP 500). Webhook callers (e.g., Slack) may expect a 401 or 403 for authentication failures. Returning 500 obscures the actual problem and may cause Slack to retry the webhook unnecessarily.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@danielkov danielkov merged commit 98d322b into main Apr 14, 2026
25 of 26 checks passed
@danielkov danielkov deleted the daniel/age-1765-feat-add-triggers branch April 14, 2026 11:46
@github-actions github-actions bot locked and limited conversation to collaborators Apr 14, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

preview Spawn a preview environment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants