Skip to content

assinafy/chat-sdk

Repository files navigation

@assinafy/chat-sdk

CI CodeQL npm version License: MIT

A unified TypeScript SDK for building chat bots and conversational integrations on top of the Assinafy document-signing API.

It includes a typed Assinafy v1 client, chat orchestration primitives, renderable card components, adapter foundations, state helpers, and optional LLM tool definitions.

Layers

Layer Subpath Purpose
Client @assinafy/chat-sdk/client Strongly-typed wrapper over every Assinafy v1 endpoint.
Cards @assinafy/chat-sdk/cards Declarative rich-message primitives (Card, Text, LinkButton, Actions, …) + renderers (text / markdown / HTML).
Adapters @assinafy/chat-sdk/adapters createMemoryAdapter() factory + BaseAdapter for vendors building real platform adapters + webhook-signature helpers.
State @assinafy/chat-sdk/state MemoryStateAdapter (subscriptions + per-thread KV). Swap in Redis/Postgres for prod.
AI tools @assinafy/chat-sdk/ai Provider-agnostic LLM tool definitions wrapping the client (Anthropic + OpenAI tool-call compatible).

All five layers can be used independently. The top-level Chat class composes them.

Installation

Each release is published to both registries.

From npmjs (default):

npm install @assinafy/chat-sdk
# or
pnpm add @assinafy/chat-sdk
# or
yarn add @assinafy/chat-sdk

From GitHub Packages — add a project-local .npmrc first so the scope resolves there:

# .npmrc
@assinafy:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

Then npm install @assinafy/chat-sdk will pull from GitHub Packages.

Requires Node 20+ (or any runtime with a global fetch + Blob).

Quick Start

import {
  Chat,
  createMemoryAdapter,
  MemoryStateAdapter,
  AssinafyClient,
  Card,
  Text,
  Divider,
  LinkButton,
  DocumentPreview,
  SignerStatus,
} from "@assinafy/chat-sdk";

const client = new AssinafyClient({
  apiKey: process.env.ASSINAFY_API_KEY!,
  accountId: process.env.ASSINAFY_ACCOUNT_ID!,
  baseUrl: "https://sandbox.assinafy.com.br/v1", // omit for production
});

const memory = createMemoryAdapter();

const chat = new Chat({
  userName: "Assinafy Bot",
  adapters: { memory },
  state: new MemoryStateAdapter(),
  client,
});

chat.onCommand("status", async (thread, msg) => {
  const id = msg.text.replace(/^\/status\s*/, "").trim();
  const doc = await client.documents.get(id);
  await thread.post({
    card: Card({
      title: "Document status",
      children: [
        DocumentPreview({
          documentId: doc.id,
          name: doc.name,
          status: doc.status,
          signingUrl: doc.signing_url ?? undefined,
        }),
        Divider(),
        SignerStatus(
          doc.assignment?.summary.signers.map((s) => ({
            name: s.full_name,
            email: s.email,
            completed: s.completed,
          })) ?? [],
        ),
      ],
    }),
    fallbackText: `Document ${doc.name}${doc.status}`,
  });
});

// Drive a message through the in-memory adapter:
await memory.receive({ text: "/status 103051797f91ce2d16a548b6a8a6" });
console.log(memory.lastSent);

Handlers

Handler When it fires
chat.onNewMention((thread, message) => …) Inbound message that mentions the bot (or opens a new thread on platforms like email where every inbound message is implicitly addressed).
chat.onSubscribedMessage((thread, message) => …) Follow-up message in a thread the bot has previously thread.subscribe()d.
chat.onNewMessage(/regex/, (thread, message) => …) Any inbound message whose text matches the regex.
chat.onCommand("status", (thread, message) => …) Slash-command syntax (/status, !status). Pass a RegExp for full control.
chat.onAction((thread, action) => …) Button click / slash-command interaction.
chat.onFallback((thread, message) => …) Catch-all when nothing else matched.

Posting messages

thread.post() accepts:

await thread.post("plain text");
await thread.post(Card({ title: "Hello", children: [Text("body")] }));
await thread.post({
  card: Card({ title: "Order Confirmed", children: [Text("Your order #1234 has been shipped.")] }),
  fallbackText: "Order #1234 confirmed",
});

Card primitives

Capitalized helpers:

import {
  Card, Text, CardText, Heading, Divider, Section, Fields, Actions,
  Button, LinkButton, Image, Table, Select, RadioSelect, Option,
  DocumentPreview, SignerStatus, Children,
} from "@assinafy/chat-sdk";

Card({
  title: "Document ready",
  children: Children(
    Heading(2, "Contract.pdf"),
    Text("Bill M asked you to sign."),
    Fields([
      { label: "Status", value: "Pending signature" },
      { label: "Expires", value: "in 7 days" },
    ]),
    Divider(),
    Actions([
      LinkButton({ label: "Sign now", url: doc.signing_url!, style: "primary" }),
      Button({ id: "decline", label: "Decline", value: doc.id, style: "danger" }),
    ]),
  ),
});

Lowercase aliases (card, text, divider, …) are also exported for tests and quick scripts.

Generic renderers ship for any adapter:

import { renderText, renderMarkdown, renderHtml } from "@assinafy/chat-sdk/cards";
const plain = renderText(message);
const md = renderMarkdown(message);
const html = renderHtml(message); // safe — values are escaped

Building a platform adapter

Adapters are constructed with a createXxxAdapter(config) factory:

import { BaseAdapter, type ChatHandle, type OutgoingMessage, type SentMessage } from "@assinafy/chat-sdk/adapters";

export interface SlackAdapterConfig {
  botToken?: string;     // falls back to SLACK_BOT_TOKEN
  signingSecret?: string; // falls back to SLACK_SIGNING_SECRET
}

class SlackAdapter extends BaseAdapter {
  readonly name = "slack";
  // override initialize, postMessage, openDM, editMessage, deleteMessage,
  // addReaction, removeReaction, startTyping as needed
}

export function createSlackAdapter(config: SlackAdapterConfig = {}): SlackAdapter {
  return new SlackAdapter(/* … */);
}

The Chat constructor calls adapter.initialize(chat) once at startup so the adapter can stash a reference. From your webhook handler, parse the request, then call:

await this.chat!.processMessage(this, normalizedIncomingMessage);
// or
await this.chat!.processAction(this, normalizedIncomingAction);

Webhook signature verification

import { verifyWebhookSignature, WebhookSignatureError } from "@assinafy/chat-sdk";

// Inside your adapter's webhook handler:
const signature = request.headers.get("x-resend-signature")!;
const body = await request.text();

try {
  verifyWebhookSignature({ secret: process.env.RESEND_WEBHOOK_SECRET!, body, signature });
} catch (err) {
  if (err instanceof WebhookSignatureError) return new Response("invalid", { status: 401 });
  throw err;
}

Supports plain HMAC-SHA256 (default), ${timestamp}.${body} payloads (pass timestamp), base64- or hex-encoded signatures, configurable hash algorithm, and an algo= prefix on the supplied signature.

Unsupported operations

Optional methods (editMessage, deleteMessage, addReaction, removeReaction, startTyping) throw NotImplementedError by default — override only what your platform supports.

The Assinafy client

Every Assinafy v1 endpoint is implemented and typed.

// Auth
await client.auth.login({ email, password });
await client.auth.createApiKey(password);
await client.auth.getApiKey();
await client.auth.deleteApiKey();

// Signers
const page = await client.signers.list(accountId, { search: "alice", perPage: 50 });
const signer = await client.signers.create(accountId, { full_name: "Alice", email: "a@x.com" });
await client.signers.update(accountId, signer.id, { whatsapp_phone_number: "+5511..." });
await client.signers.remove(accountId, signer.id);

// Documents
const doc = await client.documents.upload(accountId, { filename: "contract.pdf", body: pdfBuffer });
const docs = await client.documents.list(accountId, { status: "pending_signature" });
for await (const d of client.documents.iterate(accountId)) { /* … */ }
await client.documents.activities(doc.id);
const thumb = await client.documents.thumbnail(doc.id); // Response — stream/buffer as needed
await client.documents.remove(doc.id);

// Tags
await client.tags.create(accountId, { name: "important", color: "#ff8800" });
await client.tags.setForDocument(accountId, doc.id, ["important"]);

// Templates
const tpls = await client.templates.list(accountId);
const tpl = await client.templates.get(accountId, tpls.data[0].id); // detail incl. roles + default tags
const newDoc = await client.templates.instantiate(accountId, tpl.id, {
  signers: [{ role_id: tpl.roles![0].id, id: signer.id }],
});

// Assignments — connect signers to a document and start the flow.
const assignment = await client.assignments.create(doc.id, {
  method: "virtual",
  signers: [{ id: signer.id }],
  message: "Please sign by Friday.",
});
await client.assignments.estimateResendCost(doc.id, assignment.id, signer.id);

// Fields
const field = await client.fields.create(accountId, { type: "text", name: "Reference" });
await client.fields.validate(accountId, field.id, "ABC-123");
await client.fields.listTypes();

// Webhooks
await client.webhooks.listEventTypes();
await client.webhooks.getSubscription(accountId);
await client.webhooks.listDispatches(accountId, { perPage: 20 });

// Public document and signer flows
await client.documents.publicGet(doc.id);
await client.documents.sendPublicToken(doc.id, { recipient: "signer@example.com", channel: "email" });
await client.documents.verify("SIGNATURE_HASH");
await client.signature.self(accessCode);
await client.signature.acceptTerms(accessCode);
await client.signature.upload(accessCode, "signature", pngBytes);

Pagination

list() methods return { data, pagination }. To walk every page, use the matching iterate() async iterator.

Errors

Every non-2xx response is mapped to an ApiError with .status, .body, .path, and .method.

import { ApiError } from "@assinafy/chat-sdk";

try {
  await client.documents.remove(id);
} catch (err) {
  if (err instanceof ApiError && err.status === 409) {
    // document is not in a deletable status
  }
}

Retries

Transient failures (429, 5xx, network errors) are retried with exponential backoff. Pass maxRetries: 0 to disable.

Authentication

new AssinafyClient({ apiKey: "..." });           // X-Api-Key header
new AssinafyClient({ accessToken: "..." });      // Bearer header
AssinafyClient.fromEnv();                        // reads ASSINAFY_API_KEY / ASSINAFY_ACCESS_TOKEN

AI tool-calling

createChatTools(client) returns provider-agnostic JSON-schema tool descriptors. Compatible with both Anthropic and OpenAI tool-calling.

import Anthropic from "@anthropic-ai/sdk";
import { createChatTools, runTool, toAiMessages, defaultSystemPrompt } from "@assinafy/chat-sdk/ai";

const tools = createChatTools(client);
const anthropic = new Anthropic();

const response = await anthropic.messages.create({
  model: "claude-sonnet-4-6",
  system: defaultSystemPrompt("Assinafy Bot"),
  messages: toAiMessages(history),
  tools: tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema })),
  max_tokens: 1024,
});

for (const block of response.content) {
  if (block.type === "tool_use") {
    const result = await runTool(tools, block.name, block.input);
    // feed result back as `tool_result` in the next turn
  }
}

Restrict the toolset for guard-railed bots:

const readonly = createChatTools(client, { include: [
  "list_signers", "get_signer", "list_documents", "get_document", "document_activities",
] });

Environment variables

Variable Default Purpose
ASSINAFY_API_KEY API key (X-Api-Key)
ASSINAFY_ACCESS_TOKEN Alternative: bearer token
ASSINAFY_BASE_URL https://api.assinafy.com.br/v1 https://sandbox.assinafy.com.br/v1 for sandbox
ASSINAFY_ACCOUNT_ID Default account id
const client = AssinafyClient.fromEnv();

Testing

npm test            # full suite (unit + integration)
npm run test:unit   # unit only — no credentials needed
npm run test:integration

Integration tests skip themselves automatically when ASSINAFY_API_KEY / ASSINAFY_ACCOUNT_ID aren't set, so the suite is safe to run in CI without secrets.

Examples

See examples/ for runnable scripts:

  • basic-bot.ts — minimal in-memory bot answering status questions.
  • ai-bot.ts — same bot, with an Anthropic tool-calling loop.
  • live-cli.ts — REPL that runs against the real sandbox.

License

MIT — see LICENSE.