npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@gnldev/chat-adapter

v0.5.0

Published

Vercel AI SDK `useChat` compatibility for @gnldev/durable agents: UI message stream conversion (with sentinel masking), journal→UIMessage history reconstruction, and a Hono chat route.

Readme

@gnldev/chat-adapter

Compatibility with the Vercel AI SDK's UI layer: run a durable agent on the server, render it with useChat on the client.

Install

Install: pnpm add @gnldev/chat-adapter — or use it from a repo clone: pnpm install && pnpm -r build.

npm i @gnldev/chat-adapter

Chat route

import { createChatRoute } from '@gnldev/chat-adapter';

app.route('/api', createChatRoute({ gnl }));
// POST /api/agents/:name/chat — useChat({ api: '/api/agents/pay/chat' }) works unchanged.

The route streams back in the AI SDK's UI-message format, so an existing useChat frontend works unchanged — while the run behind it is journaled, replayable, and its side effects are at-most-once.

You normally do NOT need resolveRunId: the default derivation ${body.id}:${lastMessage.id} gives one durable run PER TURN (a network retry of the same turn replays; a new turn runs fresh). Anti-pattern to avoid: resolveRunId: (_c, body) => body.id — useChat's body.id is stable for the WHOLE conversation, so every later turn would replay turn 1 from the journal forever. What that derived string is — a name the engine hashes into an id, or the id itself — depends on whether the route can name the user; see the idempotency contract.

Who is this request for? (identity / resolveResourceId)

This route ships with no auth of its own — deliberately, and the same posture as @gnldev/agui. What that leaves you responsible for is one thing: naming the end user each run acts for.

Why it is not optional. GNL has no end-user identity of its own. An end user is a subject a trusted application names, not a principal GNL authenticates. The engine treats a few reserved context keys as "the server established this" — and an early version of this route forwarded body.context verbatim, so the reserved key arrived from whoever sent the request. Measured against a running app: a plain POST carrying {"context":{"__gnl_resourceId":"VICTIM"}} produced a run owned by that name, and the ownership stamp followed it.

The route now always seals the context, so that specific forgery is closed whether or not you pass a resolver. What a resolver decides is the other half: whether the run has an owner at all.

import { createChatRoute } from '@gnldev/chat-adapter';

const chat = createChatRoute({ gnl }, {
  // ONE hook for both fields. The same signature @gnldev/agui's route takes.
  identity: (req) => {
    const session = db.sessions.get(req.headers.get('cookie'));   // YOUR session store
    return session ? { resourceId: session.userId, threadId: session.conversationId } : undefined;
  },
});

identity receives the web Request, not the Hono context, so a host bridging this route from Express or Fastify can use it. It is called once per request and may return undefined.

Read it from something the server trusts — a session cookie, a verified JWT, principalOf(req)?.id — and never from the request body. A body-supplied subject is the caller naming whoever they like, which is the hole the context seal exists to close.

If you give none. Nothing is asserted and nothing is forged: runs are born ownerless. That is safe against impersonation and weak in the other direction — an ownership gate with no owner to compare against refuses nobody, so the protection reads as present and is not. Memory also has nothing to scope on, so per-user recall and listThreads have no subject to key by. In NODE_ENV=production the route says so once, at construction, with a console.warn — it never throws, because a deployment whose boundary genuinely lives in front of this route is not broken.

Precedence, field by field:

| Field | Order | |---|---| | resourceId | resolveResourceId(c, body)identity(req).resourceId(none) | | threadId | resolveThreadId(c, body)identity(req).threadIdbody.threadIdbody.id → the runId |

resolveResourceId / resolveThreadId still win: they are the existing contract, and a newer convenience must not quietly take a working deployment's answer away. identity outranks the body, because it is server-derived and the body is not.

Honest bound. A resolver reading an unauthenticated request asserts a subject nobody verified. Put auth in front of this route — or compose @gnldev/server's createRestApi auth middleware around it — or the subject is only as trustworthy as the caller.

The idempotency contract

Two regimes, decided by whether the route can name a subject. The per-turn key (${body.id}:${lastMessage.id}, or an Idempotency-Key header when a gateway sends one) is this route's name for the work this turn is. When identity / resolveResourceId gives that turn an owner, the key is promoted to a workKey: the engine derives the run's id from it (run1_<digest>) and the string you sent stops being a journal key. When there is nobody to name — the anonymous quickstart, no auth, no session store — the same string stays the raw runId it has always been, byte for byte. Deriving an id from a name needs an address to make it unique within, and a route with no subject has none; refusing those requests would replace a working first five minutes with an error message. Your retry contract is identical in both: the same message ids produce the same key, and the same key lands on the same run.

One migration note, because the regime is decided by the resolver: adopting this version — or wiring identity into a deployment that ran without it — changes which id an in-flight turn's retry lands on (raw key on the old pods, run1_ on the new). During that window a retried turn can run once more. Close the window by draining in-flight requests over the deploy rather than rolling through it.

  • X-Gnl-Run-Id on every response (success and error): the opaque id of the run this call landed on — a correlation handle for logs, traces and Studio. It is not your retry key: to retry, send the same turn again (the same conversation id and the same last-message id). Explicit body.runId and resolveRunId still win, and both stay raw — they name an id, and a host holding one has already decided the addressing. The Idempotency-Key header is read AFTER them (a gateway-stamped header must not silently override an application decision).
  • A per-run lock is ON by default (lock: { ttlMs: 300_000 }): two concurrent requests with the same runId (double-click, two tabs, a retry racing the original) no longer both execute — the loser gets a typed 409 { code: 'run_busy', resumable: true } + Retry-After, and retrying the same runId lands on the journal replay. lock: false restores the old behavior. Scope note: this serializes CONCURRENT duplicates; serial retries were already deduped by the runId derivation.
  • X-Gnl-Idempotency-Status on success responses: new on a fresh run, replay when this runId had prior journaled input (a retry/resume landing on journal state) — an observability contract for client-side reconciliation, not a byte-identity guarantee.
  • Typed errors instead of a flat 400: run_thread_mismatch / run_input_mismatch / run_actor_mismatch / run_swept → 409 without resumable (fix the id, not the request); run_busy → 409 + Retry-After; retry_limit_exceeded → 422; upstream provider failures → 429/502/504. Malformed messages stays a 400 — with the header contract intact.

Approvals round-trip (approve / approvalPayload)

When a tool suspends (a guard's require-approval, the confirm field, a duplicate/semantic question), the stream carries a data-gnl-interrupt chunk whose entries include the suspended run's runId — the approval's ADDRESS. A naive client that just re-POSTs its messages derives a FRESH runId from the new last-message id: the approval lands on a brand-new run and the suspended one waits forever. Use the helpers:

import { approvalPayload, approve } from '@gnldev/chat-adapter';

// useChat-style: merge the payload into YOUR next request body (same conversation id, same messages)
sendMessage(undefined, { body: approvalPayload(interrupt) });      // { runId, approvals: { [toolCallId]: true } }

// headless/manual: a convenience fetch that re-POSTs and returns the streaming Response
await approve('/api/agents/pay/chat', { interrupt, chatId, messages });

approvalPayload THROWS on an interrupt without runId rather than silently targeting a fresh run. Product rules, stated plainly: approval is a BUTTON — the route reads decisions only from body.approvals; a user typing "yes, do it" starts a fresh turn, it approves nothing. And "regenerate" with the same runId gets the journal REPLAY (the safe default); genuinely re-running a side effect goes through the approval ladder, never a silent re-execution.

Rebuilding history from the journal

import { toUIMessages } from '@gnldev/chat-adapter';

const messages = toUIMessages(await journal.list(runId));

The journal is the source of truth, so a reconnecting client can rebuild the conversation without the server holding session state.

Exports

| Export | What it is | |---|---| | createChatRoute | A handler that runs an agent and streams UI messages | | toUIMessageStream / toUIMessageStreamResponse | Converts a durable stream into the UI-message wire format, masking the internal sentinels | | toUIMessages | Journal records → UIMessage[] for history reconstruction | | approvalPayload / approve | The approval round-trip helpers — land the decision on the SUSPENDED run (see above) | | maskSentinelOutput | The shared sentinel-masking primitive (used by both live streaming and history) |

A note on history

useChat posts the entire client-side history on every turn. When the agent has memory and a threadId, the server owns the history instead — the client's copy is a view, not the record. That contract is enforced in the core, not here.

License

Apache-2.0 — see LICENSE.