Skip to content

feat(core): crispChannel private notes — crisp_send_note, replyAs: "note", post note: true - #158

Merged
linyiru merged 5 commits into
mainfrom
feat/crisp-private-notes
Aug 11, 2026
Merged

feat(core): crispChannel private notes — crisp_send_note, replyAs: "note", post note: true#158
linyiru merged 5 commits into
mainfrom
feat/crisp-private-notes

Conversation

@linyiru

@linyiru linyiru commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Crisp's private notes (same message endpoint, type: "note" — rendered in the operator inbox only, never shown to the visitor) become a first-class outbound of crispChannel. The motivating scenario: an agent running in mirror/observe mode, or not yet trusted to answer visitors autonomously, uses notes to hand reference context and a preliminary assessment to the human operators in-conversation.

Three seams, one per authorship

  1. crisp_send_note tool (agent-authored, mid-turn) — the agent leaves a private note for the human team; website/session default from the current turn's event, gated on source === "crisp" (same posture as crisp_read_conversation). Works in observe mode too: a shadow pipeline running its own turns from onEvent gets the same tool.
  2. replyAs: "note" (channel-authored, per reply) — the supervised-rollout mode: turns run normally, but the entire reply path lands as operator-only drafts. Humans review the agent's answers in-conversation; the visitor sees nothing. Flip back to "message" (default) to go live. Orthogonal to respondTo and the tool.
  3. post(target, { text, note: true }) (app-authored, deterministic) — e.g. a mirror-mode app posting its shadow turn's output as a note, no LLM choice in the loop. PostContent gains the optional note flag; Slack's post fails closed on it — silently downgrading operator-only content to a public message would leak it.

Also

  • Fixes post() hardcoding X-Crisp-Tier: "plugin" — the tier option's documented contract is "rides on every outbound call". All Crisp sends now share one postMessage (text and note differ only in type; reply path stays best-effort, post/tool check the envelope).
  • Changeset: @junejs/core patch.

Tests

  • crisp_send_note: posts type: "note" with event-defaulted targets, returns the fingerprint; guards (no Crisp event / blank content / Crisp error envelope) surface errors to the model instead of throwing
  • replyAs: "note": the turn reply lands as a note, visitor-invisible
  • post with note: true: Crisp sends a note; Slack throws
  • core: 442 pass · june: 69 pass · monorepo typecheck clean

…uman operators

Crisp's note type (same message endpoint, operator-only, invisible to the
visitor) becomes a first-class outbound, one seam per authorship:

- crisp_send_note tool: the agent hands reference context / findings / a
  preliminary assessment to the human team mid-turn, even when it should
  not (or cannot) reply to the visitor
- replyAs: "note": the supervised-rollout mode — turns run normally but
  the ENTIRE reply path lands as operator-only drafts; flip back to
  "message" to go live
- post(target, { text, note: true }): deterministic app-authored notes
  (e.g. a mirror-mode shadow turn's output). Slack's post fails closed on
  note: true — downgrading operator-only content to a public message
  would leak it

Also fixes post() hardcoding X-Crisp-Tier: "plugin" — the tier option's
contract is that it rides on every outbound call; all sends now share one
postMessage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds private Crisp notes for agent-, channel-, and app-authored outbound messages.

Changes:

  • Adds crisp_send_note and replyAs: "note".
  • Extends post() with private notes and fail-closed Slack behavior.
  • Unifies Crisp sending and honors configured API tiers.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
packages/core/src/channels.ts Implements private-note delivery.
packages/core/src/agent-config.ts Adds the note post option.
packages/core/test/channels.test.ts Tests note delivery and guards.
.changeset/crisp-private-notes.md Records the patch release.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/core/src/channels.ts Outdated
const tier = opts.tier ?? "plugin";
async function sendMessage(websiteId: string, sessionId: string, content: string) {
await fetch(`${api}/website/${websiteId}/conversation/${sessionId}/message`, {
const replyAs: "message" | "note" = opts.replyAs ?? "message";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 6ae92fd — agreed this is a confidentiality boundary, so it now fails closed at construction (replyAs must be "message" or "note"), following the auth-mode check's "backstop for plain-JS callers" precedent. Covered by a test constructing with replyAs: "notes".

Comment thread packages/core/src/channels.ts Outdated
Comment on lines +1743 to +1745
if (r.error !== false) return { error: r.reason ?? "crisp error" };
// fingerprint = the note's message identity, so the agent can reference it later
return { ok: true, fingerprint: r.data?.fingerprint };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 6ae92fd — a success envelope without a fingerprint now returns { error } instead of { ok: true, fingerprint: undefined }, aligned with post()'s strictness. Test added for the { error: false, data: {} } shape.

Comment on lines +1583 to +1592
calls = [];
globalThis.fetch = (async (url: unknown, init?: { body?: string }) => {
calls.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : undefined });
return Response.json({ error: false, reason: "dispatched", data: { fingerprint: 777 } });
}) as typeof fetch;
const crisp = crispChannel({ signingSecret: secret, identifier: "id", key: "key", apiUrl: "https://crisp.test" });
// e.g. a mirror-mode app posting its shadow turn's assessment for the human team
const posted = await crisp.post!({ channelId: "w1", threadId: "s1" }, { text: "shadow assessment: refund warranted", note: true });
expect(calls[0]!.body).toMatchObject({ type: "note", from: "operator", content: "shadow assessment: refund warranted" });
expect(posted).toEqual({ channelId: "w1", threadId: "s1", ts: "777" });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 6ae92fd — added a dedicated regression test: post() with tier: "website" asserts X-Crisp-Tier on both a text post and a note: true post, so the hardcoded-header bug cannot silently return even if the shared postMessage is ever split apart again.

…rprint, post tier regression test

- replyAs is a confidentiality boundary: an untyped typo ("notes") used to
  fall through to the visitor-visible branch, making a supervised deployment
  public. Now throws at construction, like the auth-mode backstop.
- crisp_send_note: a success envelope without a fingerprint reported
  { ok: true, fingerprint: undefined } — a false success. Now an error,
  matching post().
- the X-Crisp-Tier post() fix now has its own regression test (tier:
  "website" asserted on post, text + note).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

packages/core/src/channels.ts:1560

  • crisp_send_note now feeds model-supplied websiteId/sessionId values into this URL. Without path encoding, values containing /, ?, #, or dot segments can change the authenticated REST endpoint instead of identifying a conversation. Encode both path segments before interpolation.
    const res = await fetch(`${api}/website/${websiteId}/conversation/${sessionId}/message`, {

Comment thread packages/core/src/channels.ts Outdated
headers: { "content-type": "application/json", authorization: auth(), "X-Crisp-Tier": "plugin" },
body: JSON.stringify({ type: "text", from: "operator", origin: "chat", content: text }),
})).json().catch(() => ({}))) as { error?: boolean; reason?: string; data?: { fingerprint?: number } };
const asNote = typeof content !== "string" && content.note === true;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 10220c5 — agreed: same confidentiality argument as the replyAs guard, and crisp's strict === true was the one place a { note: "true" } fell through to the PUBLIC branch. Any non-boolean note now throws loudly on both channels (slack's truthy check had the inverse inconsistency — it accepted "true" but silently passed falsy non-booleans). Test covers note: "true" (crisp) and note: "" (slack) and asserts nothing left the process.

…hannels

The `note` flag is a confidentiality marker: an untyped caller passing
{ note: "true" } used to fall through crisp's strict `=== true` check into
the visitor-visible text branch (and slack's truthy check silently ACCEPTED
it while rejecting a falsy non-boolean). Anything but boolean/absent now
throws loudly on both channels — the post() dual of the replyAs
construction guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

packages/core/src/channels.ts:1569

  • A valid JSON null response is returned here despite the object cast; both post() and crisp_send_note then dereference r.error, causing a TypeError instead of their documented fail-closed platform error. Normalize non-object envelopes to {} as well as handling JSON parse failures.
    return (await res.json().catch(() => ({}))) as { error?: boolean; reason?: string; data?: { fingerprint?: number } };

packages/core/src/agent-config.ts:171

  • Widening the shared PostContent object does not make private delivery fail closed for exported/custom Channel implementations. Existing implementations that post content.text and ignore unknown fields continue to type-check, so { text, note: true } can be published on any non-Crisp channel despite this contract. Expose notes through a separate optional capability/method, or another channel-discriminated API that unsupported implementations cannot silently accept.
export type PostContent = string | { text?: string; blocks?: unknown[]; note?: boolean };

Comment thread packages/core/src/channels.ts Outdated
// endpoint, differing only in `type`. Returns Crisp's envelope so callers pick
// their own strictness: the reply path stays best-effort, post/tool check it.
async function postMessage(websiteId: string, sessionId: string, content: string, type: "text" | "note") {
const res = await fetch(`${api}/website/${websiteId}/conversation/${sessionId}/message`, {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 49d76dd — real injection surface (visitor message → prompt injection → tool args). Added crispPathSegment (encodeURIComponent + blank-id rejection) at all interpolation sites: both tools, postMessage, and fetchIdentity — note crisp_read_conversation had the same pre-existing hole, fixed alongside. Test asserts a "../../plugin/evil" session id stays one encoded segment and blank ids return the model-readable error with nothing sent.

Comment thread packages/core/src/channels.ts Outdated
// replyAs is a CONFIDENTIALITY boundary: a plain-JS typo ("notes") falling through
// to the visitor-visible branch would make a supervised deployment public. Fail at
// construction, like the auth-mode check — a backstop for untyped callers.
const replyAs: "message" | "note" = opts.replyAs ?? "message";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted in 49d76dd — agreed: undefined = genuinely absent (default ok), an explicit null is a confused caller and must not slide through ?? into the visitor-visible default. Now only undefined defaults; null (and any other supplied garbage) fails at construction. Test covers replyAs: null.

… closed

- websiteId/sessionId reach crisp_send_note and crisp_read_conversation as
  MODEL-SUPPLIED arguments — raw interpolation let reserved characters or
  ".." segments retarget the authenticated call. crispPathSegment encodes
  every id at ALL interpolation sites (tools, postMessage, fetchIdentity —
  the read tool had the same pre-existing hole); blank ids resolve to the
  model-readable no-target error before any request.
- replyAs: an explicit null slid through ?? into the visitor-visible
  default; only a genuinely absent option (undefined) defaults now, any
  supplied invalid value fails at construction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment on lines +1326 to +1329
function crispPathSegment(id: string): string {
if (!id.trim()) throw new Error("crisp: empty id in REST path");
return encodeURIComponent(id);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已採納 ✅ 這是真實漏洞。encodeURIComponent(".") / encodeURIComponent("..") 都不會 escape .,所以模型提供的 sessionId: ".." 編碼後仍是 ..,URL 正規化會把 /conversation/../message 收合成 /website/w1/message,改寫了已驗證的呼叫目標。

修法(commit 內):

  1. crispPathSegment 在編碼前先擋掉 trim 後恰為 ... 的 id(丟出錯誤),blank id 維持原有行為。
  2. 工具的 target() 也把 ./.. 視為「無有效目標」,比照 blank:回傳模型可讀的錯誤、不發送任何請求,而非丟例外。
  3. traversal regression test 擴充涵蓋 ... 兩個值,並同時驗證 crisp_send_notecrisp_read_conversation

已驗證:core typecheck 乾淨、445 tests pass。

encodeURIComponent leaves "." unescaped, so a model-supplied id of "."
or ".." survives encoding and URL normalization then collapses the
segment away (/conversation/../message -> /message), retargeting the
authenticated Crisp call. crispPathSegment now rejects exact "."/".."
outright before encoding, and the tools' target() treats such ids as
having no valid target (model-readable error, nothing sent) — matching
the blank-id behavior. Extends the traversal regression test to cover
both dot values on crisp_send_note and crisp_read_conversation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

packages/core/src/channels.ts:1778

  • Optional chaining only handles missing content; a model-supplied number or object has no trim method and throws, which aborts the turn rather than surfacing a tool error. The agent runtime does not validate tool calls against the declared schema before invoking run, so this needs a runtime string check.
        if (!input.content?.trim()) return { error: "note content is empty" };

packages/core/src/channels.ts:1737

  • target() handles model-supplied values, but this predicate assumes they are strings and still accepts malformed UTF-16 that encodeURIComponent rejects. Tool inputs are dispatched directly without runtime schema validation (packages/core/src/agent-runtime.ts:644), so websiteId: 1 throws here and websiteId: "\ud800" throws later in crispPathSegment, aborting the turn instead of returning the intended model-readable no-conversation result. Validate the runtime type and ensure encoding succeeds during preflight.

This issue also appears on line 1778 of the same file.

    const usable = (id?: string) => !!id && id.trim() !== "" && id.trim() !== "." && id.trim() !== "..";

@linyiru
linyiru merged commit f5cf737 into main Aug 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants