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_intake a list, e.g.in: [vendor, internal].gt/gte/lt/lteon a date field take a plain date,2026-01-15.older_than_daysmatches when the field is more than that many days old.newer_than_daysmatches when it's less. Both compare against the exact time you upload or the sweep runs. Soolder_than_days: 90means 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 ondeadline.days: the number you set (e.g.90)days_remaining: how many days are left (negative once it's passed)phase:approachingorpast
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 tosignal.firedstill 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 ontool_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
subjectblock, 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.