Getting it into your agent
One page per mod, every tool's command on it. A separate URL per tool would split the same page into five that compete with each other.
npx agentmods add agents/strelov1/freehire/notificationsgit clone --depth 1 https://github.com/strelov1/freehireWrote this? Show the measurements
A badge with what this costs and how it scanned, read live from this page, so it follows the numbers instead of freezing them. Markdown for a README, HTML for a documentation site or a project page.
[](https://agentmods.dev/agents/strelov1/freehire/notifications)<a href="https://agentmods.dev/agents/strelov1/freehire/notifications"><img src="https://agentmods.dev/badge/agents/strelov1/freehire/notifications.svg" alt="Measured on agentmods" height="20"></a>What it costs to keep this loaded
Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.
| Model | Per session | Once invoked |
|---|---|---|
| Fable 5 | $0.00000 | $0.03958 |
| Opus 5 | $0.00000 | $0.01979 |
| Sonnet 5 | $0.00000 | $0.00792 |
| Haiku 4.5 | $0.00000 | $0.00396 |
Grade A, and why
notifications scanned grade A with 0 findings against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured today.
A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.
Nothing flagged
None of the 26 patterns this scan looks for appear in this file: no shell pipes, no recursive deletes, no credential paths, no hidden text, no instruction-override or anti-refusal phrasing, no agent-config snooping. That is not a guarantee, it is the absence of the things that are checkable.
How it starts
The opening of the file, as written. The whole thing — 201 lines — stays where its author put it; the contents beside it link to each section on GitHub.
Notifications
Three independent delivery use cases share one channel vocabulary (email,
Telegram, and mobile push), each with its own small Notifier/Router pair:
| Package | Use case | Worker |
|---|---|---|
internal/engage/notify |
Filter subscriptions — new jobs matching a saved search | cmd/notify |
internal/engage/reminder |
Saved-job nudges — come back before the vacancy goes stale | cmd/remind |
internal/engage/nudge |
Lifecycle nudges — an application went silent past its stage's threshold, or moved into interview |
cmd/nudge |
internal/engage/emailnotify |
Email channel (SES) — implements notify.Notifier (the reminder/nudge-side email transports live in their own transports.go) |
— |
internal/engage/telegramnotify |
Telegram channel (Bot API, deep-link token) | — |
internal/engage/pushnotify |
Mobile push channel (Expo relay) — the bare Expo transport; each of notify/reminder/nudge has its own thin PushNotifier on top, same as Telegram/email |
— |
internal/engage/webhooknotify |
Webhook channel (plain, unsigned HTTP POST to an account's own URL) — implements notify.Notifier for notify ONLY, see the bullet below |
— |
Always true
- All three engines deliver in GROUPS, and a group is the unit of everything.
notifygroups a subscription's matched jobs;internal/engage/remindergroups an account's due reminders;internal/engage/nudgegroups an account's due nudges of one kind, andinternal/engage/reminderadditionally splits on the CHANNEL SET (see the bullet below). EveryNotifier.Sendtherefore takes a whole group — anotify.Digestfor subscriptions, a slice for the other two — and none of them takes a single item, so no per-item send path is left for a channel to keep using. That is the point: eight saves in a day were eight emails three days later. The kinds stay apart because "your application went quiet" and "prepare for your interview" are different errands with different call-to-actions; merging them would need a mail that says neither. One send outcome decides the whole group: a failure records an attempt against every member and the group returns whole on a later pass, because a partial result would need a second delivery ledger to describe and nothing reads one. A group of ONE must stay byte-identical to the pre-grouping message — that is what makes the change invisible to everyone it doesn't help. - A reminder's channels are a SNAPSHOT, so they are part of its batch key.
job_reminders.channelsis frozen when the reminder is scheduled — migration 0034 says why: "a later rule edit never rewrites a pending reminder". So an account that changed its rule between two saves has two genuinely different deliveries due, and grouping on the account alone would send one of them over the other's channels and stamp it delivered anyway. The key is(user_id, sorted channel set); only the KEY is sorted, so the send still walks the first member's own slice.internal/engage/nudgehas no such split:GetNudgeForDeliveryreadsnotification_settings.channelslive, which IS an account property. - A reminder's
fire_atis rounded forward to the account's notification hour (notification_settings.digest_timein the account's timezone; 09:00 and UTC when unset). Grouping alone would have bought the reminder engine almost nothing:fire_atwas save + 3 days exactly andfreehire-remindticks every 15 minutes, so two saves hours apart landed in different passes. It collapses a day onto TWO fire times, not one — the delay floor and a fixed hour disagree for saves that straddle that hour, so a day's saves split at it. Two messages instead of eight is the win; do not write down a promise of one.internal/engage/nudgeneeds no such rounding — it has nofire_at, and MATCH+DELIVER share a pass, so everything an account has pending is already together. - A new channel is a new
Notifierimplementation — but there are THREE of them. Each engine depends only on its ownNotifierinterface plus aRouter(amap[channel]Notifier); the three interfaces share a signature and differ in payload, andnotify/reminder/nudgeeach carry their ownRouter,ErrChannelNotConfiguredandrecipient. So adding a channel is a digest notifier, a reminder transport, a nudge transport, a case in ALL THREErecipientfunctions, and a wire-up in all threecmdmains — confirmed by push, the third channel to actually land this way (add-push-notification-channel). Collapsing the three engines into one is still not done:internal/engage/nudgewas a third use case over the same channels and didn't trigger it, and landing push — a third channel — didn't either, since the duplication is three small, near-identicalPushNotifiers (a few lines of message-rendering each) rather than three copies of anything structurally significant. Revisit if a fourth channel makes the per-engine cost look different. The fourth channel (webhook,add-saved-search-webhooks) answers that by landing in only ONE of the three engines — a saved search's "matched a job" alert is what a candidate's own tooling wants pushed to it; a saved-job reminder or a lifecycle nudge is not. So there is nowebhooknotifytransport forreminder/nudge, and neither one'scmdmain ever registersnotify.ChannelWebhookin itsRouter. Butnotify.Channelsis shared vocabulary (see the bullet below), andreminder.go's create-time gate reads it too — so an account'snotification_settings.channelswill validate"webhook"as an accepted value even though nothing ever delivers over it there. This is not a new failure mode: it is the same "valid channel, no registeredNotifier" soft-skip described below, just permanent rather than until a credential is configured. The product UI never offerswebhookas anotification_settingschoice (only the saved-search subscription control does), so this is reachable only by a direct API call — accepted as the cost of one shared vocabulary constant rather than a per-engine one. Grouping made the duplication bigger, and that is the seam to watch.reminderandnudgenow each carry a near-identicalcollect+deliverBatchpair — claim, validate per item, group, send once, finalize every member — differing only in their ledger's method names and in nudge's kind in the group key. Two copies is not yet an abstraction: a shared engine would have to be generic over the delivery row, the ledger's five statements and the message type, which is more machinery than the ~80 duplicated lines cost. What IS shared is what would silently diverge —notify.ListLimitandListed,notify.SnapshotJob/JobsSnapshot, andtelegramnotify.MaxMessageLen/UTF16Len. A third batching engine is the signal to extract the loop itself. - Push needs no server-side credential and is therefore always registered. Unlike Telegram
(
TELEGRAM_BOT_TOKEN) and email (AWS_REGION+NOTIFY_EMAIL_FROM), Expo's relay holds the APNs/FCM credential on its own side (set up once viaeas credentialsinfreehire-mobile), so everycmdmain registers the push notifier unconditionally — there is no "channel not configured" state for push, only a per-recipient one.destfor push is the recipient's user id (not a device token): a user may have zero-to-many registered devices (user_push_tokens), so each engine'srecipient()soft-skips on a liveHasPushDevicecolumn (mirroringTelegramChatID.Valid) and thePushNotifierfans the send out to every device viapushnotify.SendToDevices, which is delivered as long as at least one device received it. notify.Channelsis the single source of truth for the channel vocabulary, andnotify.ValidChannelis the membership test both create-time gates use. Subscriptions and reminders each built their ownmap[string]boolfrom the slice until the test was exported. Add a channel there or it will be creatable but undeliverable.- An unconfigured channel is a soft-skip, not a failure.
Router.SendreturnsErrChannelNotConfigured(e.g. email while SES is unset) and the engine skips it. Don't promote that to a delivery error — it would fail every run in environments without SES. In production this makes a missing credential silent, and it has already cost a channel. The mail credentials live in their own env file (/opt/freehire/.env.notify, not the/opt/freehire/.envevery worker reads); theremindandnudgeunits did not load it, so email reminders soft-skipped from the day they shipped until 2026-09-01 while every run exited 0 withfailed=0. 244 of them piled up across 43 people. The health signal for these workers is thereforesoft_skipsin the run log, not the exit code: a steady non-zero count againstdelivered=0is a dead channel, not an absence of recipients. See deploy/AGENTS.md for which units must read both files. - A blocked Telegram bot unlinks the chat; it does not fail the delivery. Every 403 the
Bot API answers a send with means the chat is permanently closed (blocked, deactivated,
bot removed), and no retry reaches it.
telegramnotify.ErrChatUnreachablecarries that up; each engine's Telegram notifier translates it into its ownErrRecipientGone, and the runner deletes the user'stelegram_linksrow and soft-skips. Matched on the 403, not on the description text — that text is prose Telegram may reword, and a rule keyed to "Forbidden: bot was blocked by the user" would stop firing silently. Unlinking rather than disabling the subscription is the point: blocking the bot is a fact about the USER, so one delete makes every telegram delivery for them — digest, reminder and nudge alike — read as "not linked" and soft-skip, while their subscriptions survive for whenever they relink. Before this, one blocked subscriber failed a digest per pass forever and keptfreehire-notify.serviceinfailed. - Matching is O(distinct queries), not O(subscribers).
notify.Runner.Rungroups subscriptions sharing a saved-search query so the search index is hit once regardless of how many people subscribed to it. A per-subscription loop would multiply index load by subscriber count. - Avoid-skills is enforced as a per-subscriber post-filter, not folded into the shared search.
Runner.matchbatch-fetches every active subscriber's liveuser_profiles.excluded_skillsonce per pass (Store.ListUserProfilesExcludedSkills) andmatchQueryskips a(hit, subscription)pair whose job carries a skill that subscriber currently avoids — evaluated against the live preference, not whateverskills_exclude(if any) got frozen into the saved search's own query string at creation time. Do not move this into the MeilisearchFilter: that would make the filter subscriber-specific and defeat the canonical-query grouping above, turning matching back into O(subscribers). - The dedup ledger's primary key is what makes matching idempotent. MATCH records matched jobs, DELIVER leases them and marks them notified — so re-scanning recent jobs never delivers twice. Preserve the two-stage split; merging match and send loses the guarantee.
internal/engage/nudge's dedup key adds an "episode key" — the fact that must change before a re-notify is warranted (an application'slast_activity_atfor a follow-up nudge, astage_setevent'soccurred_atfor interview-prep) — alongside(user, job, kind). This is what lets MATCH re-scan the same still-silent application every pass without re-pinging it: the episode key is unchanged, so the insert is a no-op againstapplication_nudges' unique index. No snooze interval, no notified-count column.internal/engage/reminder/internal/engage/notifydon't need this — a reminder fires once from a pre-scheduledfire_at, a subscription match is a distinct(subscription, job)pair already.- Every grouped message is bounded twice, and the two bounds are not the same number.
Config.SnapshotCap(200) is everything the group carries and is what the in-app notification records — what/my/notifications/:id/jobsrenders.notify.ListLimit(10) is what a channel message itemizes; the "and N more" tail is the difference. All three engines carry both, andListLimitis a single exported constant shared by all of them. They were one knob until 2026-08-21, which meant lowering the email's list length silently truncated the on-site page the email's own "view all" pointed at. BeyondSnapshotCapthe excess is RELEASED back to the pending queue, never stamped delivered: an item marked delivered while appearing in no message is gone for good. Digest.Totalislen(Jobs), and that is load-bearing. A pass can claim more matches for one subscription thanSnapshotCapallows;deliverOnecallsdeferOverflowto release the excess back to the pending queue BEFORE building the digest, so a later pass delivers it. Do not go back to truncating inbuildDigest— that stamped the overflow notified while it appeared in no message and in no snapshot, dropping those postings from the alert for good. A claimed id whose job row was pruned is deliberately NOT deferred; it is stamped notified, or it would be re-claimed every pass forever.- A subscription digest is recorded BEFORE it is sent (
RecordNotificationis:one), so the message can link to its own/my/notifications/<id>/jobs. A failed send withdraws the row (DeleteNotification) — best-effort: if that delete also fails it is logged, and one history row describes a digest nobody received. A failed recording is non-fatal in the other direction — the digest goes out withNotificationIDzero and each channel's tail falls back to/my/notifications.internal/engage/reminderandinternal/engage/nudgestill record after delivery and discard the returned id. user_notifications.jobsis one shape owned by one package.notify.SnapshotJob({title, company, slug}, migration 0091) is what all three engines write and what the single/my/notifications/:id/jobspage reads. A group of MORE than one fillsjobsand leavespublic_slugNULL; a group of one does the opposite. Three private copies of the shape would each be right until one of them changed.DigestJobdeliberately carries no internal job id — only the public slug and URL.- The Telegram link token is deliberately NOT a JWT. Telegram's deep-link
startparameter allows only 1–64 chars of[A-Za-z0-9_-], which a dotted ~200-char JWT violates, so the token is a ~43-char base64url(payload‖truncated-HMAC) blob signed withJWT_SECRET(internal/engage/telegramnotify). The 4096-char message cap is measured the way Telegram measures it — UTF-16 code units, with the widest possible "+ N more" tail reserved up front — because an oversized message fails deterministically, every retry re-fails, and the whole batch is dead-lettered.telegramnotify.MaxMessageLenandUTF16Lenare exported together for that reason: the limit without the way to measure against it inviteslen(), which counts bytes. Every engine that can build a multi-job message needs both, which since grouping is all three. - Salary fields are projected from enrichment; zero min/max or an empty currency means unknown, and the renderer omits the line rather than printing a zero.
What this file has done since we first saw it
Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.
- today Changed · +14 lines 212f1756ae93
- yesterday Changed · +63 lines f357c793ddb2
- 5d ago First seen · 124 lines · 0 tokens per session scan A 5058ff18e870
notifications is an agent published in the GitHub repository strelov1/freehire (591 stars, last pushed today), licensed MIT. It costs nothing until one of its globs matches a file; then it loads 3,958 tokens. A static security scan graded it A with 0 findings. No closer match exists in the catalogue, so it is treated as the original; first seen 2026-08-30.
Other agents, from other repositories
jobseek-labeller-extract-benefits
Extract structured fields from the benefits section — salary, equity, remote policy, visa sponsorship, annual leave, parental leave, learning budget, perks. Invoked once per posting that has a benefits section.
jobseek-labeller-extract-globals
Derive cross-section labels — profession (English), seniority (English free-text), employment type, locales, locations. Invoked once per posting after all per-section extractors have run.
jobseek-labeller-extract-requirements
Extract structured fields from the requirements section — years of experience, education, skills with category, certifications, physical requirements, clearance/licenses/background check. Invoked once per posting that has a requirements section.
jobseek-labeller-extract-role
Extract structured fields from the role section — role summary, responsibilities, collaboration partners, shift/hours/travel/on-call. Invoked once per posting that has a role section.
jobseek-labeller-extract-team
Extract structured fields from the team section of a labelled job posting — team name, team function tags. Invoked once per posting that has a team section.
jobseek-labeller-extract-preferred
Extract structured fields from the preferred section — preferred skills with category, preferred education, preferred certifications. Invoked once per posting that has a preferred section.