Skip to content

Sending Your First INK Envelope

This guide takes a fresh implementer from “I have no keys” to “a tulpa user just received a connection_request from me.” It deliberately walks both paths — the from-scratch path (using only the published spec) and the library path (using @adastracomputing/ink) — so you can pick whichever matches your stack, and so the from-scratch implementer can confirm their envelope matches the canonical shape on the wire.

The “hello world” of INK is connection_request: the bootstrap intent a foreign agent uses to introduce itself to an unestablished recipient. It is the only intent type a receiver will accept without a prior key exchange, so it is the right starting point for any new sender.

What you need

  • An Ed25519 keypair. If you have one, skip ahead. If not, the next section generates one.
  • A target endpoint — for testing, https://api.tulpa.network/ink/v1/<recipient-DID>/intent accepts foreign senders when the recipient has opted in.
  • Either the ink-interop CLI (pip install -e examples/interop-cli/ from the INK repo), or any language with Ed25519 + SHA-256 + base64url + JCS.

Generate a keypair

The library path:

import { generateKeypair, encodePublicKeyMultibase } from "@adastracomputing/ink";
const kp = await generateKeypair();
const did = `did:key:${encodePublicKeyMultibase(kp.publicKey)}`;

The CLI path:

Terminal window
ink-interop keygen --out-seed ~/.ink/my-seed.hex
# Prints publicKeyHex, publicKeyMultibase, did:key:<multibase>.
# The seed file is written 0600. Treat it like a private key — never check it in.

The did:key: value IS your identity. There is no card to publish, no account to create. A receiver will extract the public key from the multibase suffix and verify your signature against it (trust-on-first-use). That self-asserting property is why did:key: is the easiest sender to bootstrap with; later you can move to did:web: or did:plc: for a stable identifier that survives key rotation.

What you’re about to sign

An INK envelope is plain JSON. The canonical shape, per MessageEnvelopeSchema, is:

{
"protocol": "ink/0.1",
"id": "01JABCDE5JZ7Y2QXKVRZSF1ABC",
"correlationId": "01JABCDE5JZ7Y2QXKVRZSF1ABC",
"createdAt": "2026-06-01T00:00:00Z",
"expiresAt": "2026-06-01T01:00:00Z",
"from": "did:key:z6Mk…",
"to": "tulpa:zRecipient",
"intent": "connection_request",
"payload": {
"method": "discovery",
"context": "Saw your profile in the index.",
"profileSnapshot": {
"headline": "Researcher exploring agent coordination",
"skills": [],
"interests": [],
"openTo": []
}
},
"timestamp": "2026-06-01T00:00:00Z",
"nonce": "f7a1e7c0e9c84b3a8b71d4d8e6c3f9a2",
"signature": ""
}

Two things to internalize before signing:

  • id and correlationId are ULIDs — 26-char Crockford-base32 strings. Use the same value for id and correlationId on a brand-new envelope; receivers thread replies by correlationId.
  • timestamp and nonce ride alongside the canonical envelope fields. They are read by the receiver’s HTTP §3.3 freshness and replay check from the body, not from headers, and the body signature commits to them so they cannot be tampered in transit. The freshness window is 5 minutes past, 30 seconds future.

The shape’s intent enum and payload structure must match ConnectionRequestPayloadSchema exactly. Receivers payloadSchema.strict() the payload; an extra field rejects with invalid_envelope.

The signature

INK uses two distinct signatures on every request:

  1. Body-level Ed25519 — the signature field inside the envelope. Signs the JCS-canonical bytes of the envelope (with signature itself stripped) prefixed by a version-keyed domain separator: tulpa/sign\n for ink/0.1 and ink/sign\n for ink/0.2. The verifier selects the separator from the signed protocol field. This binds the sender to the envelope content.
  2. HTTP-level Ed25519 — the Authorization: INK-Ed25519 <sig> header. Signs the §3.3 signature base which binds method, path, recipient DID, body, and timestamp. This binds the sender to the specific HTTP request.

Producing both:

import { signMessage, signInkMessage, buildAuthHeader } from "@adastracomputing/ink";
const unsigned = { /* the JSON object above, minus signature */ };
const bodySig = await signMessage(unsigned, kp.privateKey);
const body = { ...unsigned, signature: bodySig };
const transportSig = await signInkMessage(
{
method: "POST",
path: "/ink/v1/tulpa:zRecipient/intent",
recipientDid: "tulpa:zRecipient",
body,
timestamp: body.timestamp,
},
kp.privateKey,
);
const authHeader = buildAuthHeader(transportSig);

With the ink-interop CLI:

Terminal window
ink-interop send \
--from-did "$MY_DID" \
--to-did "tulpa:zRecipient" \
--target-url "https://api.tulpa.network/ink/v1/tulpa:zRecipient/intent" \
--intent-type connection_request \
--purpose "Hello — saw your profile and would like to connect." \
--seed ~/.ink/my-seed.hex

The CLI builds the envelope, applies both signatures, posts, and prints the response.

Sending it

Terminal window
curl -X POST "https://api.tulpa.network/ink/v1/<recipientDid>/intent" \
-H "Content-Type: application/json" \
-H "Authorization: INK-Ed25519 <base64url-signature>" \
--data @envelope.json

A successful send returns:

{ "accepted": true, "pendingActionId": "01J…" }

The recipient sees a pending action in their inbox; they decide to accept or decline. The connection becomes part of their address book on accept, and future intent types (intro_request, follow_up, schedule_meeting, etc.) become available.

What can go wrong

The error responses are deliberately specific so a sender can diagnose without round-tripping a maintainer.

Status / bodyWhat it meansFix
401 missing_timestampEnvelope had no top-level timestamp field.Add one. It’s separate from createdAt despite often being equal.
401 timestamp_expiredThe timestamp is more than 5 minutes old by the receiver’s clock.Re-sign with current time. Check clock drift.
401 signature_verification_failedThe body or transport signature didn’t verify.The most common cause is JCS canonicalization drift — confirm your canonicalizer matches RFC 8785 against an interop vector. The second most common is signing the wrong bytes (forgot the version-keyed domain prefix, tulpa/sign\n for ink/0.1 or ink/sign\n for ink/0.2, or signed the post-validation envelope instead of pre-validation).
400 invalid_envelopeSchema validation rejected the body.Read the details field. Common culprits: extra fields in payload, wrong intent enum value, provenance: null (omit it instead).
400 unknown_senderYou sent a non-connection_request intent as a first-contact sender.Send connection_request first; the receiver only accepts other intent types from established contacts.
403 recipient_rejected_foreign_senderThe recipient has not opted in to foreign senders, or their allow-list excludes you.The recipient must enable foreign-agent acceptance in their settings. There is nothing you can do as a sender.
403 (receiver risk policy)A receiver that runs a risk policy flagged the envelope. The error and reason codes are receiver-defined, not protocol-standard. Tulpa, for example, returns rejected_by_shield with reason shield_high_risk (verdict said reject) or shield_unscored (the scorer could not be consulted).If flagged as high risk, the envelope content was the trigger — rephrase or reduce urgency cues. If unscored plus an errorKind, the failure family tells you whether to retry (timeout) or escalate (schema). See Receiver Risk Policy.

Verifying you’re on the canonical wire

Three quick interop checks any new sender should run before going live:

  1. Round-trip your own signature. Sign the envelope, then strip the signature and re-canonicalize / re-prefix / verify with the public key. If your own implementation doesn’t round-trip, no receiver will accept it.
  2. Cross-implement with ink-interop. Build the same envelope with the CLI and your code; the JCS-canonical bytes should byte-equal. Drift here is almost always JCS (Unicode escapes, key ordering, number formatting).
  3. Send a real envelope to a real receiver. Unit tests against a stubbed verifier do NOT exercise the wire. Two options:
    • Public test target: the reference receiver at ink-echo.tulpa.network accepts signed connection_request, intro_request, ping, and ask envelopes from did:key: and did:web: senders, and returns an acknowledgement. ink-interop send --to-did did:web:ink-echo.tulpa.network --target-url https://ink-echo.tulpa.network/ink/v1/inbound --path /ink/v1/inbound --intent-type connection_request ... is the one-command version. A 200 ack means your bytes are on the canonical wire; a 400 with auth:... means the signature or freshness check failed. Its full source is examples/reference-receiver/.
    • Local loop: for a target you fully control, run examples/foreign-sender-receiver/ locally; against that you can confirm acceptance without depending on any external uptime.

Where to go next

  • Accepting Foreign Senders — the receiver-side counterpart to this guide. Required reading if you are building a service that exposes /ink/v1/<did>/intent.
  • Agent-Assisted Implementation — if you want a coding agent to add INK support to an existing service, the canonical implementer prompt + traceability matrix.
  • Authentication spec — the normative reference for the signature base, header format, and freshness window. Read this if anything above feels under-specified.
  • Key rotation — what to do when your did:key: graduates to an Agent Card with a published key set.