Built for the WeMakeDevs × FalkorDB hackathon. Primary track: Agent Memory and Coordination. Secondary fit: Agents That Act on Connected Data.
Most tools that read the internet for you end the same way: a feed of links, each with a confident sentence explaining why it matters to you. That sentence is written after the fact, by a model, and nobody can check it.
NightShift does the opposite. A recommendation cannot exist unless FalkorDB returns a path from your project to the signal, and that path is the explanation.
The short version: NightShift is an always-on developer intern. It connects public technical signals to the project you are actually working on, remembers decisions across sessions, hands work between bounded agents, and explains every recommendation with the graph evidence that produced it. Nothing with a side effect runs without explicit approval.
The problem: relevant to whom?
Keyword matching can tell you an article mentions Redis. It cannot tell you whether your project needs Redis, or why.
What is missing is relationships. This project has this requirement. That requirement needs this technology. This item mentions that technology. This source published the item. Those are four hops, and a relational table with a keyword column flattens every one of them.
That is why the store had to be a graph, and why FalkorDB is the product store here, not a visualization layer bolted on at the end.
The one rule: no path, no recommendation
Every recommendation has to survive this query. It lives in the graph repository, not in an HTTP handler or an agent prompt:
MATCH path = (p:Project {id: $project_id})
-[:HAS_REQUIREMENT]->(r:Requirement)
-[:NEEDS]->(tech:Technology)
<-[:MENTIONS]-(item:Item)
-[:FROM]->(source:Source)
WHERE item.id = $item_id
RETURN p.id AS project_id,
r.id AS requirement_id,
tech.id AS technology_id,
item.id AS item_id,
source.id AS source_id,
nodes(path) AS path_nodes
LIMIT 10
In one line: User → Project → Requirement → Technology ← Item → Source.
If the query returns nothing, the item is not a recommendation. It can still be shown as unconnected ingestion data, but it never gets a graph-backed score. When it does return a path, the recommendation stores the query name, ranking version, matched IDs, source, evidence and a readable path. The UI renders the path the graph returned. It does not ask a model to invent an explanation afterwards.
Pull FalkorDB out and you do not lose a chart. You lose the relationships that make the product work.
One store, no shadow database
The first draft of our spec had SQLite as an operational store. We dropped it. FalkorDB is the only durable application state store: projects, graph paths, tasks, leases, idempotency records, memories, decisions, tool calls and approved actions.
Two stores means a question about "what did the user decide?" can be answered from the wrong one. So the rule is strict. If a crash-replay spool is ever needed, it can only be an at-least-once transport buffer, deleted after the graph acknowledges the write, and it can never answer a product query.
The practical payoff is simple: restart the daemon and the dashboard is rebuilt from FalkorDB, including the active project path and persisted tasks.
The graph layout:
| Graph | What it holds |
|---|---|
nightshift_control |
user-to-graph mapping, schema version, deployment metadata, leases and idempotency records |
nightshift_u_<stable-id> |
profile and project context, sources and items, recommendations and evidence, sessions, memories, decisions, agents, tasks, tool calls, action proposals and approvals |
User graph names come from a stable hashed identifier. A raw email, display name or token never appears in a graph name, and every user-owned node also carries tenant_id and user_id for defense-in-depth filtering.
Memory that supersedes instead of overwriting
Memory is append-oriented and typed: episodic, semantic, procedural or operational. When something changes, the new memory points at the old one with a SUPERSEDES relationship. History stays queryable. Every memory that influences a recommendation keeps its DERIVED_FROM link back to evidence.
Retrieval has a fixed order: current task and session, active project, recent decisions and feedback, stable preferences, then older graph-connected memories. Each retrieval is bounded by rows and bytes, so memory can't quietly grow into a context dump.
Agents get five tools, not a database
The agent surface is MCP, but narrow and typed. It is not an arbitrary Cypher endpoint:
context.search_relevant_itemscontext.explain_recommendationmemory.retrieveplanner.propose_planfeedback.record
Every invocation becomes a ToolCall node linked to a task and a session. The model cannot choose a graph name, run arbitrary Cypher, run shell commands, fetch arbitrary URLs, read credentials or perform a direct external write. Scope comes from the installation, never from tool input.
Coordination without a second system
Multiple daemons can share a user graph, so scheduler and task leases live in the graph too. A lease is keyed by source or task ID, with an instance heartbeat and a lease token. A worker stores its result only while its token is valid, so a stale worker cannot publish after another one recovers the lease. Abandoned work is converted to an explicit failed state by recovery routines rather than silently continuing.
Work moves between agents as Task nodes: Task -[:HANDED_OFF_TO]-> Task, Task -[:ASSIGNED_TO]-> Agent. The Relevance-to-Planner handoff is a graph relationship you can query, not a message that disappeared into a queue.
How we check it:
-
make concurrency-evidenceruns a 32-caller in-memory coordination harness 100 times under the Go race detector. -
FALKORDB_INTEGRATION=1 make integration-testruns against a live FalkorDB using unique temporary graphs, verifies graph-backed ingestion and evidence, action idempotency and concurrent lease completion, then deletes those graphs.
Approval is a property of the system, not of the prompt
"Please ask before doing anything destructive" is a wish. We wanted it to be a property of the runtime.
- External actions are disabled by default (
NIGHTSHIFT_ACTIONS_ENABLED=false). - A plan stays
proposeduntil the user explicitly approves it. Its state and idempotency record live in FalkorDB. - Tenant, user and approver come from trusted deployment config, a verified OIDC token, or a signature-verified proxy. HTTP clients cannot choose them, and a client-supplied
approved_byis ignored. - Gmail and Calendar use read-only OAuth scopes and the adapters only issue
GETrequests. GitHub access is behind a repository allowlist, and force-push and auto-merge are always refused. - Anything NightShift reads from outside is treated as hostile. Stored excerpts are bounded and tagged with
untrusted_origin(web,mail,calendar,github). Message bodies and raw pages are never persisted.
Letting an agent write code, carefully
NightShift can optionally hand a scoped coding task to OpenCode. You type something like Fix the authentication bug in a conversation, and then:
- NightShift stores the request and bounded project context as a durable task in FalkorDB.
- A serial queue worker claims it and creates an isolated Git worktree.
- OpenCode edits that worktree. NightShift collects the event stream and the diff.
- The configured tests run, and the outcome is posted back to the conversation.
The guardrails matter more than the flow:
- A Git worktree separates edits from your checkout, but it is not a sandbox, and the docs say so plainly.
- OpenCode's shell and external-directory tools are hard-denied by a server policy that is supplied outside the project code, because a task can edit files in its own worktree.
- Tests run from a trusted argument vector such as
["go","test","./..."]. A model-generated command is never executed. - Failed tests leave the task blocked for review. NightShift never commits, pushes, opens a pull request or deploys.
It runs on a small box
NightShift is one native Go daemon. It needs no Node.js, Python, Docker or SQLite at runtime. Static binaries build for old x86 PCs and ARM64 boards:
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -o dist/nightshift-linux-arm64 ./cmd/nightshift
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -o dist/nightshift-linux-amd64 ./cmd/nightshift
If a device lacks the memory for FalkorDB, the graph can stay remote. The same binary also runs under systemd on a VPS, or as a stateless service on Render, as long as every instance shares one FalkorDB endpoint.
One detail we like: /health/live never touches the graph, while /health/ready probes it and returns 503 when it is unreachable. A graph outage therefore stops traffic without sending a healthy process into a restart loop.
Try it
cp .env.example .env
set -a; source .env; set +a
docker compose -f deploy/docker/compose.yaml up -d falkordb
go mod download
go run ./cmd/nightshift
Then trigger the deterministic public-fixture flow and inspect the evidence:
curl -fsS -X POST http://127.0.0.1:8080/api/v1/runs
curl -fsS http://127.0.0.1:8080/api/v1/recommendations
curl -fsS http://127.0.0.1:8080/api/v1/graph/path/<recommendation-id>
Record feedback and turn the recommendation into a proposed plan:
curl -fsS -X POST http://127.0.0.1:8080/api/v1/recommendations/<recommendation-id>/feedback \
-H 'Content-Type: application/json' \
-d '{"kind":"useful","note":"Connected to the active project."}'
curl -fsS -X POST http://127.0.0.1:8080/api/v1/recommendations/<recommendation-id>/plan \
-H 'Content-Type: application/json' \
-d '{"goal":"Prepare the smallest implementation task."}'
The plan stays proposed until a human approves it. Open http://127.0.0.1:8080/ for the embedded status page, which shows the active graph path.
What we measured, and what we didn't
Covered by automated checks: bounded source failures and recovery, approval-gated action retries, graph evidence persistence, exactly-once lease behaviour, and the auth and configuration boundaries.
We are not claiming numbers for memory, CPU, startup time or dashboard latency, and not for any particular ARM board. Those targets stay unmeasured until the workload is run on the target hardware and the output is recorded. Deterministic mode is the baseline. Live Gmail, Calendar and search provider credentials are deployment-specific.
What we would tell someone starting a graph-backed agent
Make the invariant a query, not a prompt. "Only recommend what's relevant" is an instruction. "No path, no recommendation" is a MATCH that returns rows or doesn't.
One durable store removes a whole class of bugs. Leases, memory, decisions and tasks in the same graph means there is no second place for the truth to drift.
Narrow, typed tools beat clever prompts. Five fixed tools with scope taken from the installation are easier to reason about, and to audit, than one flexible one.
State what you haven't measured. A hackathon project that says "this number is unmeasured" is more believable than one that guesses.
Top comments (0)