Escape valve first: if a module you write ends up misbehaving (spamming
overdue items, wedging the gate), gatekit off stops gating entirely,
unconditionally, with no rate limit -- see the README's Escape valves
section. That always works regardless of what any module does, so don't
panic-delete anything; just run it.
A module is anything that periodically writes one JSON file to
sources/<module>.json. That's the entire interface. gatekit never calls,
spawns, imports, or otherwise knows about a module -- it only reads that
directory. A module can be a cron job, a long-running daemon, a one-off
script, written in any language, living in any repo, running on any
schedule you like.
The file must match schema/source.schema.json. See
schema/example-source.json for a minimal worked example.
Write to a temp file in the same directory as your target, then rename it into place. Never write your target file in place with a truncating write -- gatekit (or a concurrent read from your own module) can observe a half-written file mid-write, and a truncate-then-write window means it can observe an empty one.
func writeAtomic(path string, v any) error {
b, _ := json.MarshalIndent(v, "", " ")
f, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp) // no-op once the rename below succeeds
f.Write(b)
f.Close()
return os.Rename(tmp, path) // atomic on the same filesystem
}cmd/gatekit-stalefiles/main.go's own writeAtomic is the reference
implementation; copy it if you're writing Go, or match the same
temp-file-then-rename shape in whatever language you're using.
first_seen_at is the timestamp an item's SLA countdown is measured from.
It must be stamped once, the first time your module sees a given item
id, and then carried forward unchanged on every later run for as long as
that id keeps appearing. If your module re-stamps it to "now" on every run,
the SLA never elapses and the item can never become due --
silently defeating the whole point of an SLA.
The concrete pattern (from gatekit-stalefiles): read back your own
previous output file at the start of each run, build a map of id -> first_seen_at from it, and consult that map before generating this run's
items:
previous := map[string]time.Time{}
if prev, err := readPrevious(outPath); err == nil {
for _, it := range prev.Items {
previous[it.ID] = it.FirstSeenAt
}
}
firstSeen, seenBefore := previous[id]
if !seenBefore {
firstSeen = now
}An id that's no longer relevant should simply be omitted from the next
run's items -- absence is the signal that it's resolved. There is no
separate "resolved" or "cleared" flag to set.
The grace period, in seconds, after first_seen_at before this item is
even eligible to block. 0 means eligible immediately. Most modules want
something nonzero -- gatekit's own tiering will hold the item in pending
(visible, never blocking, with a "becomes overdue in Xm" countdown) until
the SLA elapses.
ok: false tells gatekit your own check didn't run cleanly this time
(couldn't reach an API, couldn't read a directory, whatever "couldn't tell"
means for your module). Combined with file-mtime staleness (which gatekit
computes itself and you cannot misreport), this is the health signal.
on_degraded decides what your degraded state does to your items:
"open"(the default -- use this unless you have a specific reason not to): due items go toheldinstead ofoverduewhile you're unhealthy. This is fail-open: a broken module goes quiet, not silent, and never blocks anyone based on data it isn't confident in."closed": a due item still blocks exactly as if you were healthy, and gatekit always attaches a warning explaining that your module was degraded when it did. Reach for this only when "I can't tell" is itself the problem you're trying to surface -- most checks are not this.
There is no other way to make a degraded module block silently, on
purpose. If you want closed, the warning ships with it, always.
gatekit reload (see the README's Reload section) asks every module
to re-poll now instead of waiting out its own schedule. gatekit cannot
actually make your module do anything -- it only writes a marker file and
reports, as a warning, whenever your source file's mtime predates the most
recent reload request. Nothing breaks if you never look at this; the worst
case is your module catches up on its own next scheduled run, same as
always.
If you'd rather respond immediately, watch the mtime of
$GATEKIT_STATE_DIR/reload-requested (default
~/.cache/gatekit/state/reload-requested) alongside your own normal
schedule, and trigger an out-of-cycle run when it's newer than the mtime
you last acted on. Compare mtimes, not the file's content -- the content is
a human-readable timestamp for anyone debugging with cat, but mtime is
the one fact any language can check without decoding anything. If you poll
an external API to do this, debounce on your own side (a naive per-tick
check is fine; blindly re-polling on every single reload request in a
tight loop is not) -- gatekit places no rate limit on reload itself,
since it can never let an overdue item through, only ask you to hurry.
gatekit itself has zero domain knowledge -- if you find yourself writing integration-specific logic (ticket systems, chat platforms, workflow state), that belongs entirely in your module. gatekit will never grow built-in support for any of it; that's deliberate.