feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers) (#2119)

* feat(zapier): add Hindsight Zapier app (actions + REST Hook triggers)

A Zapier Platform CLI app that brings Hindsight memory into Zaps.

Actions:
- Retain Memory (create) -> POST /v1/default/banks/{bank}/memories
- Recall Memories (search) -> POST .../memories/recall
- Reflect (search) -> POST .../reflect

Triggers (instant, via Hindsight's webhook API — subscribe POSTs /webhooks,
unsubscribe DELETEs it):
- Retain Completed, Consolidation Completed, Memory Defense Triggered

Auth: API key as Bearer token, Cloud default with self-hosted override; the
Bank field is a dynamic dropdown from GET /v1/default/banks.

Built on zapier-platform-core 19; 'private': true so the npm release path can
never publish it (Zapier publishing is manual via zapier push/promote, not
release-integration.yml — and zapier is intentionally NOT in VALID_INTEGRATIONS).

Adds test-zapier-integration CI job (npm install -> zapier validate -> npm test)
and a repo README row. 15 mocha/nock unit tests; 'zapier validate' is
structurally clean.

Docs-site gallery card + doc page + icon are a follow-up (need the official
Zapier brand asset; omitted here to keep build-docs green).

* fix(zapier): make apiKey optional for no-auth self-hosted + prettier-clean

- authentication.js: apiKey now optional (required: false). The middleware only
  adds the Bearer header when a key is present, so you can connect to a
  self-hosted instance running without auth by leaving it blank; Cloud still
  requires a working key (blank -> 401 fails the connection test).
- README: document self-hosted / localhost usage, the optional key, and correct
  the CLI binary name to 'zapier-platform' (v19 renamed it from 'zapier'); show
  the .env approach so 'zapier invoke' needs no global install.
- Run prettier across the integration (fixes pre-existing format drift that was
  failing verify-generated-files on this branch).

zapier validate still structurally sound; 15 tests pass.

* fix(zapier): correct reflect answer field + recall output shape (found via live test)

Extensive live testing against Hindsight Cloud surfaced two response-shape bugs
the mocked unit tests missed (they mocked the wrong shapes):

- reflect: the synthesized answer is in the response's `text` field, not
  `answer`. searches/reflect read `data.answer` (undefined), so a Zap got no
  answer. Now reads `data.text` and surfaces it as `answer`. Test mock fixed to
  use the real `text` field so it actually guards this.
- recall: results carry no numeric `score`, and the fact-type field is `type`
  (not `fact_type`). Corrected the sample + outputFields so the Zap editor only
  advertises fields that actually populate; test mock made realistic.

Verified live end-to-end: auth, bank dropdown, retain (full + minimal), recall
(real fact extraction), reflect (now returns the grounded answer), and the
webhook subscribe/list/delete lifecycle. 15 unit tests pass; zapier validate clean.

* docs(zapier): correct .env auth-field prefix to authData_ in README

zapier invoke reads .env auth fields with the authData_ prefix (e.g.
authData_apiKey, authData_apiUrl), not bare apiKey/apiUrl. Confirmed against a
working local .env during live testing.

* docs(zapier): add integrations gallery card + doc page

- gallery entry in integrations.json (id zapier, official, category framework)
- doc page docs-integrations/zapier.md (actions + REST Hook triggers, setup)
- official Zapier logo at static/img/icons/zapier.png

check-integrations passes (forward: entry → doc page); JSON valid; prettier-clean.

* feat(zapier): verify webhook HMAC signatures + optional async retain (review notes 2 & 4)

#2 — Webhook signature verification (was: relying only on Zapier's unguessable URL):
- performSubscribe now generates a random 32-byte secret and registers it with
  the webhook; the secret is stored in subscribeData.
- perform verifies the X-Hindsight-Signature: sha256=<hmac> header (HMAC-SHA256
  of the raw body) and rejects mismatches. (Corrected the header name — the API
  sends X-Hindsight-Signature, not X-Webhook-Signature; body is delivered
  byte-for-byte via content=, so the recomputed HMAC matches.)

#4 — Optional 'Process asynchronously' toggle on Retain (default false). Lets
users with very large content avoid Zapier's action timeout; pairs with the
Retain Completed trigger.

17 unit tests pass (added valid/invalid signature cases); zapier validate clean.
This commit is contained in:
Ben
2026-06-15 16:44:04 -04:00
committed by GitHub
parent 78b1df8d8a
commit 359b2bc762
25 changed files with 13998 additions and 0 deletions
+34
View File
@@ -49,6 +49,7 @@ jobs:
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }}
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
integrations-zapier: ${{ steps.filter.outputs.integrations-zapier }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
@@ -161,6 +162,8 @@ jobs:
- 'hindsight-integrations/cursor/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
- 'hindsight-integrations/zapier/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-superagent:
@@ -730,6 +733,37 @@ jobs:
working-directory: ./hindsight-integrations/n8n
run: npm run build
test-zapier-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zapier == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/zapier
run: npm install --no-fund --no-audit
- name: Validate app definition
working-directory: ./hindsight-integrations/zapier
run: npm run validate
- name: Run tests
working-directory: ./hindsight-integrations/zapier
run: npm test
test-hindsight-agent-sdk:
needs: [detect-changes]
if: >-
@@ -0,0 +1,85 @@
---
sidebar_position: 36
title: "Zapier Persistent Memory with Hindsight | Integration"
description: "Add long-term memory to your Zaps with Hindsight. Retain, Recall, and Reflect actions plus instant memory-event triggers connect Hindsight to 7,000+ apps."
---
# Zapier
Long-term agent memory for [Zapier](https://zapier.com) via [Hindsight](https://hindsight.vectorize.io). The Hindsight Zapier app adds three actions — **Retain**, **Recall**, **Reflect** — plus instant **triggers** that start a Zap when a memory event fires, so memory flows between Hindsight and 7,000+ apps.
## Why this matters
Zapier connects everything: Gmail, Slack, Sheets, HubSpot, Notion, forms, and thousands more. On its own, those Zaps are **stateless**. With Hindsight you can:
- **Retain** every closed ticket, form submission, or call summary into a memory bank
- **Recall** relevant context before an AI step so the model sees prior history
- **Reflect** to get a synthesized, memory-grounded answer right inside a Zap
- **Trigger** a Zap the moment a memory operation completes (e.g. notify Slack when consolidation finishes)
## Setup
:::tip Recommended: Hindsight Cloud
[Sign up free](https://ui.hindsight.vectorize.io/signup) and grab an API key — no self-hosting required.
:::
1. **Sign up** at [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup) (free tier) or [self-host](/developer/installation)
2. **Get an API key** (`hsk_...`) from the Hindsight dashboard
3. **In Zapier**, add a Hindsight step and connect your account with the API key (the API URL defaults to Hindsight Cloud; point it at your own instance for self-hosted — leave the key blank if it runs without auth)
## Actions
### Retain
Store content in a bank. Hindsight extracts facts asynchronously after the call returns.
| Field | Description |
| --------- | --------------------------------------------------------------------- |
| Bank | Memory bank to store in (dynamic dropdown; auto-created on first use) |
| Content | Free text to retain |
| Context | Optional context for the content |
| Tags | Comma-separated tags |
| Timestamp | When the content occurred (defaults to now) |
### Recall (search)
Search a bank for memories relevant to a query.
| Field | Description |
| ----------------- | ---------------------- |
| Bank | Memory bank to search |
| Query | Natural-language query |
| Budget | `low` / `mid` / `high` |
| Tags / Tags Match | Optional tag filter |
### Reflect (search)
Get an LLM-synthesized answer grounded in the bank's memories.
| Field | Description |
| ------ | ---------------------- |
| Bank | Memory bank |
| Query | Question to answer |
| Budget | `low` / `mid` / `high` |
## Triggers
Instant triggers (REST Hooks) that fire when a memory event completes in a bank:
| Trigger | Fires when |
| ---------------------------- | ------------------------------------------------------------- |
| **Retain Completed** | An asynchronous retain finishes processing |
| **Consolidation Completed** | Memory consolidation synthesizes observations / mental models |
| **Memory Defense Triggered** | The memory-defense filter redacts or blocks incoming content |
## Example Zaps
**Support assistant** — a closed-ticket trigger (Zendesk) → Hindsight **Retain** the resolution. New ticket → Hindsight **Recall** similar past issues → OpenAI drafts the first reply.
**Memory digest** — Hindsight **Consolidation Completed** trigger → format the new observations → post to Slack so the team sees what the agent learned.
**Daily prep** — a calendar trigger → Hindsight **Reflect** ("What do we know about this prospect?") → append the answer to the prep doc.
## Source
- GitHub: [`hindsight-integrations/zapier`](https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/zapier)
+10
View File
@@ -320,6 +320,16 @@
"link": "/sdks/integrations/n8n",
"icon": "/img/icons/n8n.png"
},
{
"id": "zapier",
"name": "Zapier",
"description": "Long-term memory for your Zaps. Retain, Recall, and Reflect actions plus instant memory-event triggers connect Hindsight to 7,000+ apps.",
"type": "official",
"by": "hindsight",
"category": "framework",
"link": "/sdks/integrations/zapier",
"icon": "/img/icons/zapier.png"
},
{
"id": "openai-agents",
"name": "OpenAI Agents SDK",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+1
View File
@@ -45,6 +45,7 @@ Each integration lives in its own subdirectory with its own README, configuratio
| Integration | What it does |
| --- | --- |
| [**n8n**](./n8n) | Community node — drop retain/recall/reflect into any n8n workflow. |
| [**Zapier**](./zapier) | Zapier app — retain/recall/reflect actions plus instant memory-event triggers. |
| [**Dify**](./dify) | Persistent memory for Dify apps. |
| [**Flowise**](./flowise) | Memory nodes for Flowise flows. |
| [**Vapi**](./vapi) | Persistent memory for Vapi voice agents. |
+6
View File
@@ -0,0 +1,6 @@
node_modules/
build/
.env
.environment
.zapierapprc
*.log
+77
View File
@@ -0,0 +1,77 @@
# Hindsight for Zapier
A [Zapier](https://zapier.com) app that brings [Hindsight](https://hindsight.vectorize.io) long-term memory into your Zaps — store content, search memories, get grounded answers, and start Zaps from memory events.
Built with the [Zapier Platform CLI](https://platform.zapier.com/). The source lives in the Hindsight monorepo for versioning and CI; the app itself is published to Zapier's platform with `zapier-platform push` / `zapier-platform promote` (see [Publishing](#publishing)).
## What's included
### Actions
- **Retain Memory** (create) — store content in a memory bank (`POST /memories`).
- **Recall Memories** (search) — search a bank with a natural-language query (`POST /memories/recall`).
- **Reflect** (search) — get an LLM-synthesized, memory-grounded answer (`POST /reflect`).
### Triggers (instant, via REST Hooks)
Each subscribes to Hindsight's webhook API (`POST /webhooks`) and is removed on teardown (`DELETE /webhooks/{id}`):
- **Retain Completed** — `retain.completed`
- **Consolidation Completed** — `consolidation.completed`
- **Memory Defense Triggered** — `memory_defense.triggered`
The **Bank** field on every action/trigger is a dynamic dropdown populated from `GET /v1/default/banks` (you can also type a new bank id — banks are created on first use).
## Authentication
API-key auth. Provide:
- **API Key** — your Hindsight key (starts with `hsk_`), sent as `Authorization: Bearer <key>`. Required for Hindsight Cloud; **optional** — leave it blank — for a self-hosted instance running without authentication.
- **API URL** — defaults to Hindsight Cloud (`https://api.hindsight.vectorize.io`); set it to your own instance for self-hosted (e.g. `http://localhost:8888`).
### Self-hosted
Point **API URL** at your instance. If it runs without auth, leave **API Key** blank — no `Authorization` header is sent. Triggers also work self-hosted: they rely on your instance making an _outbound_ POST to Zapier's webhook URL, which works for any box with outbound internet (only fully air-gapped instances can't).
> Each trigger registers its webhook with a freshly generated HMAC secret and verifies the `X-Hindsight-Signature: sha256=<hmac>` header on every delivery, rejecting any payload whose signature doesn't match.
## Development
```bash
npm install
npm run validate # zapier validate — structural check, no login needed
npm test # mocha + nock unit tests, no network
```
Live checks against a real instance. `zapier invoke` reads credentials from a local
`.env` (gitignored) — write it directly to skip the interactive `auth start`:
```bash
# `zapier invoke` reads auth fields from .env with an `authData_` prefix.
# Hindsight Cloud:
printf "authData_apiKey=hsk_your_key\nauthData_apiUrl=https://api.hindsight.vectorize.io\n" > .env
# …or self-hosted without auth:
printf "authData_apiKey=\nauthData_apiUrl=http://localhost:8888\n" > .env
npx zapier-platform invoke auth test
npx zapier-platform invoke trigger bankList
npx zapier-platform invoke create retain --inputData '{"bank_id":"zapier-test","content":"hello"}'
npx zapier-platform invoke search recall --inputData '{"bank_id":"zapier-test","query":"hello"}'
```
> The CLI binary is `zapier-platform` (v19 renamed it from `zapier`). `npx zapier-platform …`
> uses the local devDependency, so no global install or PATH setup is needed.
## Publishing
Requires a Zapier developer account (`npx zapier-platform login`); cannot run in CI without a `ZAPIER_DEPLOY_KEY`.
```bash
npx zapier-platform register "Hindsight" # first time only — writes .zapierapprc (gitignored)
npx zapier-platform push # upload the current version (private/invite testing)
npx zapier-platform promote 1.0.0 # make a version the default for new users
```
Public-directory listing is a separate, manual step through Zapier's app-review (branding, descriptions, and a dedicated Hindsight Cloud test bank + API key for reviewers).
> **Release note:** this integration is **not** released via the monorepo's `release-integration.sh` / `release-integration.yml` (those handle PyPI/npm). It is published to Zapier manually. `package.json` is marked `"private": true` so it can never be accidentally `npm publish`ed. Do not add `zapier` to `VALID_INTEGRATIONS` in `release-integration.sh`.
@@ -0,0 +1,50 @@
"use strict";
const { baseUrl, DEFAULT_API_URL } = require("./utils");
/**
* Custom API-key authentication.
*
* Mirrors the n8n `hindsightApi` credential: an API key plus an optional base
* URL (Hindsight Cloud by default, overridable for self-hosted). The key is
* sent as a Bearer token by the shared `beforeRequest` middleware; the `test`
* call below exercises the credential against a real authenticated endpoint.
*/
const test = (z, bundle) => z.request({ url: `${baseUrl(bundle)}/v1/default/banks` });
const connectionLabel = (z, bundle) => {
const host = ((bundle.authData && bundle.authData.apiUrl) || DEFAULT_API_URL).replace(
/^https?:\/\//,
""
);
return `Hindsight (${host})`;
};
module.exports = {
type: "custom",
test,
connectionLabel,
fields: [
{
key: "apiKey",
label: "API Key",
type: "password",
// Optional so you can connect to a self-hosted instance running without
// auth (leave blank). For Hindsight Cloud a key is required — a blank key
// there fails the connection test (401), as expected.
required: false,
helpText:
"Your Hindsight API key (starts with `hsk_`). Required for Hindsight Cloud; leave blank for a self-hosted instance running without authentication. Create one at https://ui.hindsight.vectorize.io.",
},
{
key: "apiUrl",
label: "API URL",
type: "string",
required: false,
default: DEFAULT_API_URL,
helpText:
"Hindsight API base URL. Defaults to Hindsight Cloud; change it for a self-hosted instance (e.g. http://localhost:8888).",
},
],
};
@@ -0,0 +1,97 @@
"use strict";
const { baseUrl, parseTags, enc } = require("../utils");
/**
* Retain — store content in a Hindsight memory bank.
*
* POST /v1/default/banks/{bank_id}/memories
* body: { items: [{ content, context?, tags?, timestamp? }], async: false }
*/
const perform = async (z, bundle) => {
const item = { content: bundle.inputData.content };
if (bundle.inputData.context) item.context = bundle.inputData.context;
const tags = parseTags(bundle.inputData.tags);
if (tags.length) item.tags = tags;
if (bundle.inputData.timestamp) item.timestamp = bundle.inputData.timestamp;
const response = await z.request({
method: "POST",
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bundle.inputData.bank_id)}/memories`,
body: { items: [item], async: bundle.inputData.async === true },
});
return response.data;
};
module.exports = {
key: "retain",
noun: "Memory",
display: {
label: "Retain Memory",
description:
"Store content in a Hindsight memory bank. Hindsight extracts facts, entities, and relationships from the text.",
},
operation: {
inputFields: [
{
key: "bank_id",
label: "Bank",
required: true,
dynamic: "bankList.bank_id.name",
helpText:
"The memory bank to store into. A bank is created on first use; you can also type a new bank id.",
},
{
key: "content",
label: "Content",
type: "text",
required: true,
helpText: "The text to store as a memory.",
},
{
key: "context",
label: "Context",
type: "string",
required: false,
helpText: "Optional context describing where this content came from.",
},
{
key: "tags",
label: "Tags",
type: "string",
required: false,
helpText: 'Comma-separated tags, e.g. "user:alex,scope:profile".',
},
{
key: "timestamp",
label: "Timestamp",
type: "datetime",
required: false,
helpText: "When this content occurred. Defaults to now if left blank.",
},
{
key: "async",
label: "Process asynchronously",
type: "boolean",
required: false,
default: "false",
helpText:
"Return immediately and process in the background. Enable for very large content that might exceed Zapier's action timeout; pair it with the 'Retain Completed' trigger to act on completion.",
},
],
perform,
sample: {
success: true,
bank_id: "user-123",
items_count: 1,
async: false,
operation_id: "op_abc123",
},
outputFields: [
{ key: "success", label: "Success", type: "boolean" },
{ key: "bank_id", label: "Bank ID" },
{ key: "items_count", label: "Items Count", type: "integer" },
{ key: "operation_id", label: "Operation ID" },
],
},
};
+46
View File
@@ -0,0 +1,46 @@
"use strict";
const authentication = require("./authentication");
const { addBearerHeader, handleHttpError } = require("./middleware");
const retain = require("./creates/retain");
const recall = require("./searches/recall");
const reflect = require("./searches/reflect");
const bankList = require("./triggers/banks");
const retainCompleted = require("./triggers/retainCompleted");
const consolidationCompleted = require("./triggers/consolidationCompleted");
const memoryDefenseTriggered = require("./triggers/memoryDefenseTriggered");
const App = {
version: require("./package.json").version,
platformVersion: require("zapier-platform-core").version,
// Don't let the platform auto-strip/trim input — our perform functions handle
// empty/optional fields explicitly, and this keeps behavior predictable.
flags: { cleanInputData: false },
authentication,
// Inject the Bearer header and normalize errors for every request.
beforeRequest: [addBearerHeader],
afterResponse: [handleHttpError],
triggers: {
[bankList.key]: bankList,
[retainCompleted.key]: retainCompleted,
[consolidationCompleted.key]: consolidationCompleted,
[memoryDefenseTriggered.key]: memoryDefenseTriggered,
},
creates: {
[retain.key]: retain,
},
searches: {
[recall.key]: recall,
[reflect.key]: reflect,
},
};
module.exports = App;
@@ -0,0 +1,37 @@
"use strict";
/**
* Request/response middleware shared by every operation.
*
* `addBearerHeader` injects the Hindsight API key on every outbound request so
* individual operations don't have to. `handleHttpError` turns non-2xx
* responses into typed Zapier errors with a useful message.
*/
const addBearerHeader = (request, z, bundle) => {
if (bundle.authData && bundle.authData.apiKey) {
request.headers = request.headers || {};
request.headers.Authorization = `Bearer ${bundle.authData.apiKey}`;
}
return request;
};
const handleHttpError = (response, z) => {
if (response.status === 401 || response.status === 403) {
throw new z.errors.Error(
"Invalid or unauthorized Hindsight API key.",
"AuthenticationError",
response.status
);
}
if (response.status >= 400) {
throw new z.errors.Error(
`Hindsight API error ${response.status}: ${response.content}`,
"ApiError",
response.status
);
}
return response;
};
module.exports = { addBearerHeader, handleHttpError };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
{
"name": "@vectorize-io/zapier-hindsight",
"version": "1.0.0",
"description": "Hindsight long-term memory for Zapier — retain, recall, reflect, and memory-event triggers",
"license": "MIT",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-integrations/zapier"
},
"homepage": "https://hindsight.vectorize.io",
"author": {
"name": "Vectorize",
"email": "support@vectorize.io"
},
"main": "index.js",
"scripts": {
"test": "mocha --recursive -t 10000 test",
"validate": "zapier-platform validate",
"build": "zapier-platform build",
"push": "zapier-platform push"
},
"dependencies": {
"zapier-platform-core": "19.0.0"
},
"devDependencies": {
"mocha": "^10.7.0",
"nock": "^13.5.0",
"should": "^13.2.3",
"zapier-platform-cli": "^19.0.0"
},
"engines": {
"node": ">=20",
"npm": ">=9"
}
}
@@ -0,0 +1,101 @@
"use strict";
const { baseUrl, parseTags, enc } = require("../utils");
/**
* Recall — search a memory bank for relevant memories.
*
* POST /v1/default/banks/{bank_id}/memories/recall
* body: { query, budget, tags?, tags_match?, max_tokens? } -> { results: [...] }
*
* Modeled as a Zapier *search* (read-only lookup). A search must return an
* array, so we return `results` directly.
*/
const perform = async (z, bundle) => {
const body = {
query: bundle.inputData.query,
budget: bundle.inputData.budget || "mid",
};
const tags = parseTags(bundle.inputData.tags);
if (tags.length) {
body.tags = tags;
body.tags_match = bundle.inputData.tags_match || "any";
}
if (bundle.inputData.max_tokens) body.max_tokens = bundle.inputData.max_tokens;
const response = await z.request({
method: "POST",
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bundle.inputData.bank_id)}/memories/recall`,
body,
});
return response.data.results || [];
};
module.exports = {
key: "recall",
noun: "Memory",
display: {
label: "Recall Memories",
description:
"Search a Hindsight memory bank for memories relevant to a natural-language query.",
},
operation: {
inputFields: [
{ key: "bank_id", label: "Bank", required: true, dynamic: "bankList.bank_id.name" },
{
key: "query",
label: "Query",
type: "string",
required: true,
helpText: "Natural-language query to search memories with.",
},
{
key: "budget",
label: "Budget",
type: "string",
required: false,
default: "mid",
choices: { low: "Low (fast)", mid: "Medium", high: "High (thorough)" },
helpText: "How exhaustive the retrieval should be.",
},
{
key: "tags",
label: "Tags",
type: "string",
required: false,
helpText: "Comma-separated tags to filter by (leave blank for no filter).",
},
{
key: "tags_match",
label: "Tags Match",
type: "string",
required: false,
default: "any",
choices: { any: "Any", all: "All", any_strict: "Any (strict)", all_strict: "All (strict)" },
},
{
key: "max_tokens",
label: "Max Tokens",
type: "integer",
required: false,
helpText: "Maximum tokens of memories to return.",
},
],
perform,
sample: {
id: "123e4567-e89b-12d3-a456-426614174000",
text: "Marcus is a marine biologist.",
type: "world",
context: "research background",
tags: ["person:marcus"],
},
// Recall results are returned pre-ranked by the server; the response carries
// no numeric score, and the fact-type field is `type` (not `fact_type`).
outputFields: [
{ key: "id", label: "ID" },
{ key: "text", label: "Text" },
{ key: "type", label: "Type" },
{ key: "context", label: "Context" },
],
},
};
@@ -0,0 +1,65 @@
"use strict";
const { baseUrl, enc } = require("../utils");
/**
* Reflect — get an LLM-synthesized answer grounded in a bank's memories.
*
* POST /v1/default/banks/{bank_id}/reflect
* body: { query, budget } -> { answer, based_on }
*
* Modeled as a Zapier *search*. A search must return an array, so the single
* synthesized answer is wrapped in a one-element array with a stable `id`.
*/
const perform = async (z, bundle) => {
const response = await z.request({
method: "POST",
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bundle.inputData.bank_id)}/reflect`,
body: { query: bundle.inputData.query, budget: bundle.inputData.budget || "mid" },
});
const data = response.data || {};
// The reflect response carries the synthesized answer in `text` (not `answer`);
// surface it to Zaps under the friendlier `answer` key.
return [{ id: "reflect", answer: data.text, based_on: data.based_on }];
};
module.exports = {
key: "reflect",
noun: "Answer",
display: {
label: "Reflect",
description:
"Get an LLM-synthesized answer to a question, grounded in a Hindsight memory bank's memories.",
},
operation: {
inputFields: [
{ key: "bank_id", label: "Bank", required: true, dynamic: "bankList.bank_id.name" },
{
key: "query",
label: "Query",
type: "string",
required: true,
helpText: "The question to answer using the bank's memories.",
},
{
key: "budget",
label: "Budget",
type: "string",
required: false,
default: "mid",
choices: { low: "Low (fast)", mid: "Medium", high: "High (thorough)" },
helpText: "How deep the synthesis should be.",
},
],
perform,
sample: {
id: "reflect",
answer: "Jon's favorite band is Tool.",
based_on: { memories: [], mental_models: [] },
},
outputFields: [
{ key: "id", label: "ID" },
{ key: "answer", label: "Answer" },
],
},
};
@@ -0,0 +1,51 @@
"use strict";
require("should");
const zapier = require("zapier-platform-core");
const nock = require("nock");
const App = require("../index");
const appTester = zapier.createAppTester(App);
describe("authentication", () => {
afterEach(() => nock.cleanAll());
const authData = { apiKey: "hsk_test", apiUrl: "https://api.example.com" };
it("tests the credential against GET /v1/default/banks with a Bearer header", async () => {
const scope = nock("https://api.example.com", {
reqheaders: { authorization: "Bearer hsk_test" },
})
.get("/v1/default/banks")
.reply(200, { banks: [] });
const response = await appTester(App.authentication.test, { authData });
response.status.should.eql(200);
scope.isDone().should.be.true();
});
it("throws an AuthenticationError on 401", async () => {
nock("https://api.example.com").get("/v1/default/banks").reply(401, { error: "nope" });
await appTester(App.authentication.test, { authData }).should.be.rejectedWith(
/Invalid or unauthorized/
);
});
it("strips a trailing slash from the API URL", async () => {
const scope = nock("https://api.example.com")
.get("/v1/default/banks")
.reply(200, { banks: [] });
await appTester(App.authentication.test, {
authData: { apiKey: "hsk_test", apiUrl: "https://api.example.com/" },
});
scope.isDone().should.be.true();
});
it("builds a connection label from the host", () => {
const label = App.authentication.connectionLabel({}, { authData });
label.should.eql("Hindsight (api.example.com)");
});
});
@@ -0,0 +1,61 @@
"use strict";
require("should");
const zapier = require("zapier-platform-core");
const nock = require("nock");
const App = require("../index");
const appTester = zapier.createAppTester(App);
const authData = { apiKey: "hsk_test", apiUrl: "https://api.example.com" };
describe("creates.retain", () => {
afterEach(() => nock.cleanAll());
it("POSTs to the memories endpoint with content, tags, and async:false", async () => {
nock("https://api.example.com")
.post("/v1/default/banks/bank-1/memories", {
items: [{ content: "hello", tags: ["a", "b"] }],
async: false,
})
.reply(200, { success: true, bank_id: "bank-1", items_count: 1, operation_id: "op-1" });
const result = await appTester(App.creates.retain.operation.perform, {
authData,
inputData: { bank_id: "bank-1", content: "hello", tags: "a, b" },
});
result.operation_id.should.eql("op-1");
result.success.should.be.true();
});
it("omits tags when none are given and includes context/timestamp when present", async () => {
nock("https://api.example.com")
.post("/v1/default/banks/bank-1/memories", {
items: [{ content: "hi", context: "ctx", timestamp: "2026-01-01T00:00:00Z" }],
async: false,
})
.reply(200, { success: true, bank_id: "bank-1", items_count: 1 });
await appTester(App.creates.retain.operation.perform, {
authData,
inputData: {
bank_id: "bank-1",
content: "hi",
context: "ctx",
timestamp: "2026-01-01T00:00:00Z",
},
});
});
it("URL-encodes the bank id", async () => {
const scope = nock("https://api.example.com")
.post("/v1/default/banks/user%2F1/memories")
.reply(200, { success: true });
await appTester(App.creates.retain.operation.perform, {
authData,
inputData: { bank_id: "user/1", content: "hi" },
});
scope.isDone().should.be.true();
});
});
@@ -0,0 +1,65 @@
"use strict";
require("should");
const zapier = require("zapier-platform-core");
const nock = require("nock");
const App = require("../index");
const appTester = zapier.createAppTester(App);
const authData = { apiKey: "hsk_test", apiUrl: "https://api.example.com" };
describe("searches.recall", () => {
afterEach(() => nock.cleanAll());
it("returns the results array (not the envelope) and defaults budget to mid", async () => {
nock("https://api.example.com")
.post("/v1/default/banks/bank-1/memories/recall", { query: "bands", budget: "mid" })
.reply(200, { results: [{ id: "1", text: "Tool", type: "world" }] });
const results = await appTester(App.searches.recall.operation.perform, {
authData,
inputData: { bank_id: "bank-1", query: "bands" },
});
results.should.be.an.Array();
results.length.should.eql(1);
results[0].text.should.eql("Tool");
});
it("sends tags and tags_match only when tags are present", async () => {
nock("https://api.example.com")
.post("/v1/default/banks/bank-1/memories/recall", {
query: "q",
budget: "high",
tags: ["x"],
tags_match: "all",
})
.reply(200, { results: [] });
const results = await appTester(App.searches.recall.operation.perform, {
authData,
inputData: { bank_id: "bank-1", query: "q", budget: "high", tags: "x", tags_match: "all" },
});
results.should.eql([]);
});
});
describe("searches.reflect", () => {
afterEach(() => nock.cleanAll());
it("surfaces the reflect `text` field as `answer` in a one-element array", async () => {
// The real reflect response puts the synthesized answer in `text`, not `answer`.
nock("https://api.example.com")
.post("/v1/default/banks/bank-1/reflect", { query: "fav band?", budget: "mid" })
.reply(200, { text: "Tool.", based_on: { memories: [] } });
const results = await appTester(App.searches.reflect.operation.perform, {
authData,
inputData: { bank_id: "bank-1", query: "fav band?" },
});
results.should.be.an.Array();
results.length.should.eql(1);
results[0].id.should.eql("reflect");
results[0].answer.should.eql("Tool.");
});
});
@@ -0,0 +1,115 @@
"use strict";
require("should");
const crypto = require("crypto");
const zapier = require("zapier-platform-core");
const nock = require("nock");
const App = require("../index");
const appTester = zapier.createAppTester(App);
const authData = { apiKey: "hsk_test", apiUrl: "https://api.example.com" };
describe("triggers.bankList", () => {
afterEach(() => nock.cleanAll());
it("maps banks to { bank_id, name } for the dropdown", async () => {
nock("https://api.example.com")
.get("/v1/default/banks")
.reply(200, { banks: [{ bank_id: "b1", name: "Bank One" }, { bank_id: "b2" }] });
const banks = await appTester(App.triggers.bankList.operation.perform, { authData });
banks.should.eql([
{ id: "b1", bank_id: "b1", name: "Bank One" },
{ id: "b2", bank_id: "b2", name: "b2" },
]);
});
});
describe("triggers.retainCompleted (REST hook)", () => {
afterEach(() => nock.cleanAll());
it("subscribes with a generated secret and returns { id, bank_id, secret }", async () => {
nock("https://api.example.com")
.post("/v1/default/banks/bank-1/webhooks", (body) => {
// body carries the targetUrl, event type, enabled flag, and a hex secret.
return (
body.url === "https://hooks.zapier.com/abc" &&
body.event_types[0] === "retain.completed" &&
body.enabled === true &&
typeof body.secret === "string" &&
body.secret.length >= 32
);
})
.reply(201, { id: "wh-1" });
const result = await appTester(App.triggers.retainCompleted.operation.performSubscribe, {
authData,
inputData: { bank_id: "bank-1" },
targetUrl: "https://hooks.zapier.com/abc",
});
result.id.should.eql("wh-1");
result.bank_id.should.eql("bank-1");
result.secret.should.be.a.String();
});
it("unsubscribes by deleting the registered webhook", async () => {
const scope = nock("https://api.example.com")
.delete("/v1/default/banks/bank-1/webhooks/wh-1")
.reply(200, { success: true });
await appTester(App.triggers.retainCompleted.operation.performUnsubscribe, {
authData,
subscribeData: { id: "wh-1", bank_id: "bank-1" },
});
scope.isDone().should.be.true();
});
it("surfaces the inbound payload when there is no secret to verify", async () => {
const event = {
event: "retain.completed",
bank_id: "bank-1",
operation_id: "op-1",
status: "completed",
};
const result = await appTester(App.triggers.retainCompleted.operation.perform, {
authData,
cleanedRequest: event,
});
result.should.eql([event]);
});
it("accepts a delivery with a valid HMAC signature", async () => {
const secret = "s3cr3t";
const raw = '{"event":"retain.completed","bank_id":"bank-1"}';
const sig = "sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex");
const result = await appTester(App.triggers.retainCompleted.operation.perform, {
authData,
subscribeData: { id: "wh-1", bank_id: "bank-1", secret },
cleanedRequest: { event: "retain.completed", bank_id: "bank-1" },
rawRequest: { content: raw, headers: { "X-Hindsight-Signature": sig } },
});
result[0].event.should.eql("retain.completed");
});
it("rejects a delivery with a bad HMAC signature", async () => {
await appTester(App.triggers.retainCompleted.operation.perform, {
authData,
subscribeData: { id: "wh-1", bank_id: "bank-1", secret: "s3cr3t" },
cleanedRequest: { event: "retain.completed", bank_id: "bank-1" },
rawRequest: {
content: '{"event":"retain.completed","bank_id":"bank-1"}',
headers: { "X-Hindsight-Signature": "sha256=deadbeef" },
},
}).should.be.rejectedWith(/signature verification failed/i);
});
it("returns a sample from performList", async () => {
const result = await appTester(App.triggers.retainCompleted.operation.performList, {
authData,
});
result.should.be.an.Array();
result[0].event.should.eql("retain.completed");
});
});
@@ -0,0 +1,104 @@
"use strict";
const crypto = require("crypto");
const { baseUrl, enc } = require("../utils");
/**
* Builds a Zapier REST Hook trigger backed by Hindsight's webhook API.
*
* On subscribe we register a Hindsight webhook scoped to a single bank and
* event type, pointed at Zapier's per-Zap `targetUrl`, with a freshly generated
* HMAC secret. On unsubscribe we delete it. Each inbound delivery's signature is
* verified before the event reaches the Zap.
*
* Signature: Hindsight signs the raw delivery body and sends
* `X-Hindsight-Signature: sha256=<hex>` where `<hex>` is
* HMAC-SHA256(secret, rawBody). We recompute it over `bundle.rawRequest.content`
* (the body is sent byte-for-byte, so this matches) and reject on mismatch.
*
* Endpoints:
* POST /v1/default/banks/{bank_id}/webhooks -> { id, ... }
* DELETE /v1/default/banks/{bank_id}/webhooks/{webhook_id}
*/
/** Case-insensitive header lookup (Zapier may lower-case header keys). */
const getHeader = (headers, name) => {
if (!headers) return undefined;
const want = name.toLowerCase();
for (const k of Object.keys(headers)) {
if (k.toLowerCase() === want) return headers[k];
}
return undefined;
};
const makeHookTrigger = ({ key, noun, label, description, eventType, sample }) => {
const performSubscribe = async (z, bundle) => {
const secret = crypto.randomBytes(32).toString("hex");
const response = await z.request({
method: "POST",
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bundle.inputData.bank_id)}/webhooks`,
body: {
url: bundle.targetUrl,
event_types: [eventType],
enabled: true,
secret,
},
});
// Persisted as `bundle.subscribeData` for unsubscribe + signature checks.
return { id: response.data.id, bank_id: bundle.inputData.bank_id, secret };
};
const performUnsubscribe = async (z, bundle) => {
const { id, bank_id } = bundle.subscribeData;
const response = await z.request({
method: "DELETE",
url: `${baseUrl(bundle)}/v1/default/banks/${enc(bank_id)}/webhooks/${enc(id)}`,
});
return response.data;
};
// Inbound delivery — verify the HMAC signature, then surface the parsed event.
const perform = (z, bundle) => {
const secret = bundle.subscribeData && bundle.subscribeData.secret;
if (secret) {
const raw = (bundle.rawRequest && bundle.rawRequest.content) || "";
const got = getHeader(
bundle.rawRequest && bundle.rawRequest.headers,
"X-Hindsight-Signature"
);
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex");
if (got !== expected) {
throw new z.errors.Error("Webhook signature verification failed.", "SignatureError", 401);
}
}
return [bundle.cleanedRequest];
};
// No "list past events" endpoint exists, so the test step returns a sample.
const performList = () => [sample];
return {
key,
noun,
display: { label, description },
operation: {
type: "hook",
inputFields: [
{
key: "bank_id",
label: "Bank",
required: true,
dynamic: "bankList.bank_id.name",
helpText: "The memory bank to watch for events.",
},
],
performSubscribe,
performUnsubscribe,
perform,
performList,
sample,
},
};
};
module.exports = { makeHookTrigger };
@@ -0,0 +1,36 @@
"use strict";
const { baseUrl } = require("../utils");
/**
* Hidden trigger that powers the "Bank" dynamic dropdown used by every action
* and trigger. Referenced as `dynamic: 'bankList.bank_id.name'`.
*
* GET /v1/default/banks -> { banks: [{ bank_id, name, ... }] }
*/
const perform = async (z, bundle) => {
const response = await z.request({ url: `${baseUrl(bundle)}/v1/default/banks` });
// Zapier requires an `id` on every trigger result (its dedup key); bank_id is
// unique, so reuse it. The dropdown ref `bankList.bank_id.name` uses bank_id
// as the value and name as the label.
return (response.data.banks || []).map((b) => ({
id: b.bank_id,
bank_id: b.bank_id,
name: b.name || b.bank_id,
}));
};
module.exports = {
key: "bankList",
noun: "Bank",
display: {
label: "List Banks",
description: "Internal trigger that populates the Bank dropdown.",
hidden: true,
},
operation: {
perform,
canPaginate: false,
sample: { bank_id: "user-123", name: "User 123" },
},
};
@@ -0,0 +1,25 @@
"use strict";
const { makeHookTrigger } = require("./_hookFactory");
module.exports = makeHookTrigger({
key: "consolidationCompleted",
noun: "Consolidation",
label: "Consolidation Completed",
description:
"Triggers when memory consolidation finishes — observations and mental models have been synthesized.",
eventType: "consolidation.completed",
sample: {
event: "consolidation.completed",
bank_id: "user-123",
operation_id: "op_def456",
status: "completed",
timestamp: "2026-06-10T12:05:00Z",
data: {
observations_created: 2,
observations_updated: 1,
observations_deleted: 0,
error_message: null,
},
},
});
@@ -0,0 +1,26 @@
"use strict";
const { makeHookTrigger } = require("./_hookFactory");
module.exports = makeHookTrigger({
key: "memoryDefenseTriggered",
noun: "Memory Defense",
label: "Memory Defense Triggered",
description:
"Triggers when Hindsight's memory-defense filter redacts or blocks incoming content (e.g. detected secrets or PII).",
eventType: "memory_defense.triggered",
sample: {
event: "memory_defense.triggered",
bank_id: "user-123",
operation_id: "op_ghi789",
status: "completed",
timestamp: "2026-06-10T12:10:00Z",
data: {
action: "redact",
detector: "secret_scanner",
document_id: "doc-2",
matched_types: ["api_key"],
message: "Redacted 1 secret before storing.",
},
},
});
@@ -0,0 +1,20 @@
"use strict";
const { makeHookTrigger } = require("./_hookFactory");
module.exports = makeHookTrigger({
key: "retainCompleted",
noun: "Retain",
label: "Retain Completed",
description:
"Triggers when an asynchronous retain operation finishes processing in a memory bank.",
eventType: "retain.completed",
sample: {
event: "retain.completed",
bank_id: "user-123",
operation_id: "op_abc123",
status: "completed",
timestamp: "2026-06-10T12:00:00Z",
data: { document_id: "doc-1", tags: ["user:jon"] },
},
});
+27
View File
@@ -0,0 +1,27 @@
"use strict";
const DEFAULT_API_URL = "https://api.hindsight.vectorize.io";
/**
* Base URL for API calls. Defaults to Hindsight Cloud; trailing slash is
* stripped so path concatenation never produces a double slash (mirrors the
* n8n node's URL handling).
*/
const baseUrl = (bundle) =>
((bundle.authData && bundle.authData.apiUrl) || DEFAULT_API_URL).replace(/\/$/, "");
/** Parse a comma-separated tag string into a trimmed, non-empty array. */
const parseTags = (raw) =>
!raw
? []
: String(raw)
.split(",")
.map((t) => t.trim())
.filter(Boolean);
module.exports = {
DEFAULT_API_URL,
baseUrl,
parseTags,
enc: encodeURIComponent,
};