Writing a custom signal definition

For: an admin, or the engineer they've asked to write the file. Takes about 15 minutes.

What this does

A custom signal is a rule your own organization writes and uploads. You don't send a request to Stacktic, and there's no code in a Stacktic repo. You describe a condition in a small YAML (or JSON) file. For example: "this tool's had no activity in 180 days," or "this person hasn't logged in to this tool in 90." Stacktic checks it every night against your own data. It's the same way Stacktic checks its own built-in signals. See What is a signal? for what a signal is and what it does once it fires. This article is about writing the file.

There's one master switch for the whole feature: the Custom signal card on Settings → Signals. Each definition also has its own on/off switch underneath it. Turning the master switch off stops every definition at once, whatever its own status says.

Before you start

  • You need to be an Owner to upload a definition, at Settings → Signals → Custom signals.
  • If you want Stacktic to DM the affected person, your organization needs Slack connected. Everything else works without Slack, such as:
    • the signal appearing on the dashboard;
    • assigning it to a tool owner.
  • The file is yours. Stacktic doesn't keep a version history beyond the one currently uploaded. Your own repo (or wherever you keep the file) is the history. You can download any definition back out at any time.

A complete example

Here's Help Scout's own first custom signal. Help Scout's account-management tool, called Super, locks an account after 90 days with no login. It does this silently, with no warning to anyone. This definition warns people twice before that happens, and lets them know once it's fixed:

format: 1                          # format version, required
key: super-inactivity-lockout      # org-unique [a-z0-9-], ≤64 chars; the rule's stable identity
title: Super inactivity lockout    # ≤120 chars
description: Super locks accounts after 90 days without a login. Warn people before it happens.
severity: info                     # info | warning | critical
dataset: tool_access               # which dataset this reads — see "Datasets" below
where:                             # AND of typed filters — see "Filters" below
  - { field: tool.slug, is: super }
deadline:                          # optional — "N days after a date field" — see "Deadlines" below
  field: last_login_at
  days: 90
condition:
  within_days_of_deadline: 14      # holds when the deadline is ≤14 days away, or already past
notify:                            # see "Who gets told" below
  channel: false                   # org signals-channel card (default true)
  owner: false                     # assign to the tool's primary owner (default true on tool-bearing datasets)
  subject:                         # DM the person — only on datasets that have one
    before_deadline_days: [14, 3]
    message: |
      You haven't signed in to *{{tool.name}}* since {{last_login_at | date}}.
      {{tool.name}} locks accounts after {{deadline.days}} days without a login — yours locks
      on *{{deadline | date}}* ({{deadline | days_until}} days from now). Sign in before then to keep it.
    resolved_message: "✅ You signed in to {{tool.name}} on {{last_login_at | date}} — your account stays active."
summary: "{{person.name}} hasn't signed in to {{tool.name}} in {{last_login_at | days_since}} days — locks {{deadline | relative}}"

The rest of this article works through each part of that file.

The keys, one by one

Every definition is one YAML or JSON document with a fixed set of keys. No other key is accepted. So a typo is caught the moment you upload, not silently ignored.

Key Required? What it is
format Yes Must be the number 1. It's the version of this format.
key Yes The rule's stable identity. Lowercase letters, numbers, and hyphens only, up to 64 characters, unique in your organization. Changing key on a re-upload creates a new definition rather than updating the old one. See "Lifecycle" below.
title Yes A short name, up to 120 characters. Shown everywhere the signal appears.
description No Up to 500 characters. It's for your own reference. It isn't shown to the people the signal notifies.
severity Yes info, warning, or critical. If you have a deadline, it can instead be two different severities for before and after it (see below).
dataset Yes Which kind of row you're checking. See "Datasets" below. Can't be changed on a re-upload. Delete it and recreate it under a new key instead.
where No A list of filters. Leave it out and every row matches.
deadline No "N days after a date field." It turns this into a countdown.
condition Only with deadline How close to (or past) the deadline counts as a match.
notify No Who hears about it, and how.
summary Yes The one-line description shown wherever the signal appears.

Here's a severity that varies by phase. Each side must still be one of info/warning/critical. It's only allowed together with deadline:

severity:
  approaching: info
  past: critical

Datasets

dataset picks what one row represents. There are two today:

  • tool_access: one row per person's active access to a managed tool. Use this when the thing you're checking is about a person and a tool together. For example, "has this person logged in recently," or "how long have they had access." Only this dataset can DM the affected person.
  • tool: one row per managed tool, with no person attached. Use this for things about the tool itself. For example, "has anyone used this at all," or "does it have a primary owner."

Both only ever see tools Stacktic is actively managing. Both only see people who are currently employed (full-time or contractor). These never show up as a row to match against:

  • someone who's left the company;
  • a shared or service account.

tool_access fields:

Field Type Notes
tool.name text
tool.slug text The tool's short identifier, e.g. super.
tool.origin one of: vendor, internal
tool.category text Can be empty.
person.name text
person.email text
person.employment_status one of: active, contractor
person.is_contractor yes/no
last_login_at date Can be empty (never logged in). The same "last activity" Stacktic shows on the tool's Access tab.
granted_at date Can be empty. When access was granted.
last_seen_at date Can be empty.
role text Can be empty. The person's role in the tool's own words, e.g. Owner.
logins_90d number How many times this person signed in to this tool in the last 90 days. It counts Google sign-ins, plus logins your own systems report to Stacktic. A tool's own activity feed doesn't count here. For "has this person used the tool," use last_login_at.

tool fields:

Field Type Notes
tool.name text
tool.slug text
tool.origin one of: vendor, internal
tool.category text Can be empty.
tool.status always active Every row this dataset returns is already a currently-managed tool. So this field never varies. It's here for completeness, not because you'll ever filter on it.
last_activity_at date Can be empty.
created_at date When Stacktic started managing it.
active_user_count number How many people currently have access.
admin_count number How many admins the tool has.
has_primary_owner yes/no
requires_offboarding yes/no Whether removing access on this tool is part of your offboarding process.
seat_count number Can be empty. How many seats the vendor itself lists for this tool, from its most recent sync in the last 90 days. It's empty if the last successful sync is older than that, even if older readings exist. When a tool has more than one connected account, it's the total across all of them.
seat_count_delta_90d number Can be empty. How the seat count changed over the last 90 days: the newest reading minus the oldest reading in that window. Negative means seats were removed. Empty until there are at least two days of readings.
active_seats_90d number How many people with access show any activity in the last 90 days. It's the "N of M seats active" figure on the tool's Access tab.
seats_without_activity_feed number How many people with access Stacktic has never seen any activity for. This means Stacktic can't see them, not that they're idle. Many tools don't report activity at all.
tool.price_per_seat_cents number Can be empty. Your answer to the tool's Price per seat field, in the smallest unit of its currency. That's cents for US dollars, so $12.50 is 1250. Prices aren't converted between currencies, so only compare tools priced in the same one. Empty if nobody has entered a price.
tool.price_per_seat_currency text Can be empty. The currency of that price, as a three-letter code like USD.
tool.plan_seats number Can be empty. Your answer to the tool's Seats included in plan field.

For example, here are tools that grew by 10 or more seats in the last 90 days:

dataset: tool
where:
  - { field: seat_count_delta_90d, gte: 10 }
summary: "{{tool.name}} added {{seat_count_delta_90d}} seats in 90 days (now {{seat_count}})"

Filters (where)

where is a list of conditions. All of them must be true for a row to match. There's no "or." If you need an either/or rule, write two separate definitions.

Each entry names one field and exactly one comparison:

Field type Comparisons you can use
text is, is_not, in, not_in
one-of-a-list (like tool.origin) is, is_not, in, not_in
yes/no is, is_not
number is, is_not, gt, gte, lt, lte
date gt, gte, lt, lte, older_than_days, newer_than_days

Any field that can be empty also accepts is_null and is_not_null (written as is_null: true), whatever its type. Every other comparison simply doesn't match a row where that field is empty. So you generally don't need to combine the two.

where:
  - { field: tool.slug, is: super }
  - { field: tool.origin, in: [vendor, internal] }
  - { field: last_login_at, older_than_days: 60 }
  - { field: active_user_count, gte: 1 }

Notes on the less obvious ones:

  • in / not_in take a list, e.g. in: [vendor, internal].
  • gt/gte/lt/lte on a date field take a plain date, 2026-01-15.
  • older_than_days matches when the field is more than that many days old. newer_than_days matches when it's less. Both compare against the exact time you upload or the sweep runs. So older_than_days: 90 means strictly more than 90 days, not 90-or-more.

Deadlines

Add deadline when the signal is really a countdown to something, like an account that'll lock or a certificate that'll expire. It's a date field plus a number of days:

deadline:
  field: last_login_at
  days: 90

That's "90 days after last_login_at." Pair it with condition.within_days_of_deadline to say how early the signal should start firing:

condition:
  within_days_of_deadline: 14

This holds (meaning the row counts as a match) once the deadline is 14 days away or closer, or has already passed. It does not un-match once the deadline passes. A signal about someone about to be locked out shouldn't quietly disappear once they actually are. That would read as "they're fine now" when the opposite is true. If you want it to resolve automatically, that has to come from the underlying data changing, not from time simply running out. For example, they log in and last_login_at moves forward.

A row with an empty deadline field (nobody's ever logged in, say) never matches a deadline rule. There's nothing to count down from.

Writing the summary and messages

summary is a small template. So are the subject.message / subject.resolved_message you'll meet below. A template is plain text with {{ variable }} dropped in, and optionally a filter: {{ variable | filter }}. There's no "if," no loop, and no math. It's just substitution.

You can use any field from your dataset, spelled the same way you'd use it in where (tool.name, person.email, and so on). If you have a deadline, four more become available:

  • deadline: the actual date it falls on
  • deadline.days: the number you set (e.g. 90)
  • days_remaining: how many days are left (negative once it's passed)
  • phase: approaching or past

There are four filters, and they work only on date values. That means last_login_at, deadline, and so on. They don't work on deadline.days or days_remaining, which are plain numbers:

Filter Example output
date Sep 22, 2026
days_since 10
days_until 5
relative in 8 days, 3 days ago

An empty value renders as —. Anything you type around a {{ }} is left exactly as you wrote it, like the *asterisks* for Slack bold in the example above. Only the substituted value itself gets safely escaped for wherever it's shown. So you never need to escape it yourself.

Who gets told (notify)

There are three separate audiences, all optional:

  • channel: a card in your organization's signals channel in Slack. Defaults to on. Turning it off affects Slack only. A webhook endpoint subscribed to signal.fired still hears about every fire.
  • owner: assigns the signal to the tool's primary owner, the same as a built-in signal would. Defaults to on for any dataset that has a tool. Today that's both of them, so in practice this is always on unless you turn it off.
  • subject: DMs the affected person directly. It's only on tool_access, the dataset with a person. Off by default. You opt in by adding the block.

A subject block needs where or deadline to narrow things down. Without either one, it would try to DM every person the dataset can see. That's refused at upload.

subject:
  before_deadline_days: [14, 3]     # only with a deadline — up to 3, closest-to-zero last
  message: "..."                    # required
  resolved_message: "..."           # optional — sent once, when the condition clears

With a deadline, before_deadline_days lists how many days out to send a warning. [14, 3] means "warn at 14 days left, warn again at 3." Only one message goes out per sweep, at whichever stage is currently due. So nobody gets two warnings in the same night, even if they were somehow overdue for both. Nothing more is sent once the deadline has passed. There's no third "you're overdue" DM. Without a deadline, subject sends exactly one message, the moment the signal first fires.

resolved_message is optional. It only sends if the condition clears on its own. Stacktic re-checks each open instance and DMs it once. This matches how a built-in signal's resolution is quiet everywhere else. Every DM needs the person to have signed in to Slack at least once, so Stacktic knows who they are there. If they haven't, the DM is simply skipped rather than sent nowhere.

You can also loop in the owner or channel after the deadline. This is on top of whatever channel/owner already do at fire time:

subject:
  ...
  escalate:
    when: past_deadline          # or { after_days: 7 } — 7 days past the deadline
    to: [owner]                  # owner and/or channel

You can only escalate to an audience that isn't already notified at fire time. Say notify.owner is already true. Escalating to the owner then is a contradiction, not a no-op, and it's refused at upload.

Limits

Limit Value What happens if you hit it
Definitions per organization 25 Uploading a 26th is refused. Delete one first.
Matches per definition, per nightly check 1,000 The definition is marked Errored rather than applied. Preview warns you before you enable it.
Subject DMs, per definition, per day 200 Once it's hit, people the cap cut off that night usually just get their DM the next night instead, since the stage is still due. A message is only lost outright if that stage's own window closes before then.
Warning stages 3 before_deadline_days can list at most 3 numbers.
File size 64 KB Comfortably above any real definition. The example above is about 2 KB.
title / description / key 120 / 500 / 64 characters

Lifecycle

Upload a file at Settings → Signals → Custom signals. It starts as a Draft: stored, but not yet checked against anything. Preview shows you:

  • how many rows match right now;
  • a sample of up to 20;
  • if you have a subject block, how many people would actually be DM'd, and how many of those aren't reachable because they've never signed in to Slack.

There's no way to turn a definition on without going through Preview first.

Preview & enable makes it Active. It's checked immediately, once, so it shows up on the dashboard right away. But it's quiet, with no notifications, so enabling something never blasts out DMs for everything that already matches. From then on it's part of the nightly check like any other signal.

Re-uploading a file with the same key replaces its content and keeps whatever state it was already in:

  • re-uploading a Draft leaves it a Draft;
  • re-uploading an Active definition asks you to confirm, since it's already live.

Stacktic keeps only the current version. There's no history beyond that, since the file is yours to keep.

Disable takes a definition out of the nightly check and closes any signal it currently has open. Delete does the same, permanently. It also frees up the key to be reused later, if you ever want to.

A definition can go Errored from hitting the 1,000-match cap, or from a problem the nightly check hit. If that happens, fix the underlying issue and re-upload. A plain re-upload alone doesn't bring it back, since it's still the config that caused the error. Preview & enable again once you're ready.

When it runs

Every Active definition is checked once a night, at 03:00 UTC. Enabling or re-uploading a definition also checks it immediately (quietly, as above), so you see accurate results right away. But the nightly check is the only thing that sends notifications. There's no separate trigger for something like a login. Say a condition clears the moment someone signs in. That's picked up at the next nightly check, up to 24 hours later, not instantly.

Dates in a DM sent to a person show in their own time zone. Dates on the shared channel card or in the app show in your organization's time zone.

A second example

Stacktic's own idea list has long included flagging a tool nobody's touched in a while. That's exactly the kind of thing this feature is for. Any organization can write it today:

format: 1
key: abandoned-app
title: Abandoned app
description: A managed tool nobody has touched in six months is probably a seat we're still paying for.
severity: warning
dataset: tool
where:
  - { field: last_activity_at, older_than_days: 180 }
notify:
  channel: true
  owner: true
summary: "{{tool.name}} has had no activity in {{last_activity_at | days_since}} days"

There's no deadline and no subject DM. It's just a channel card and an owner assignment, the moment a tool crosses six months of silence.

Troubleshooting

Every problem is reported with the exact key it's about. So you can go straight to it in your file.

What you'll see What to do
"unknown key "…"" That key isn't part of the format. Check its spelling against the table above.
"format: must be 1" format has to be the literal number 1.
"op "…" is not valid for field "…"" That comparison doesn't apply to that field's type. Check the filters table above.
"filter "…" is not valid on "…"" You used a filter like date on something that isn't a date value.
"unknown template variable "…"" That name isn't a field on your dataset. It also isn't one of deadline / deadline.days / days_remaining / phase.
"a subject definition with neither where nor deadline is refused" Add a filter or a deadline to narrow down who gets DM'd.
dataset change refused You can't change dataset on an existing key. Delete the definition and re-upload under a new key.
"Your org already has 25 custom signal definitions" Delete one before adding another.
"match cap exceeded" Your where (or lack of one) is matching more than 1,000 rows. Narrow it.
"larger than the 64 KB limit" Your file is unusually large for a definition. Check for something pasted in by accident.
"stages must be strictly decreasing" List before_deadline_days from largest to smallest, e.g. [14, 3] not [3, 14].

Frequently asked questions

Can I write this in JSON instead of YAML? Yes. Upload a .json file with the same keys, and Stacktic reads it the same way.

Can I match "this OR that"? Not within one definition. where is always "all of these." Write two definitions instead.

Can I edit a definition in the app instead of re-uploading? No. There's no builder in the app. Change your file and upload it again under the same key.

Where's the history of what I've changed? In your own copy of the file. Stacktic only keeps the current version.

See also

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.