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>/intentaccepts foreign senders when the recipient has opted in. - Either the
ink-interopCLI (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:
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 and never check it in.The did:key: value IS your identity. There is no card to publish and no account to create, and that is both why it is the easiest sender to bootstrap with and the limit of what it can do. A did:key identifier embeds a key and names no location, so it has no discovery surface: nobody can resolve it to an Agent Card, and INK’s resolver reports it unresolvable without making a request. What a receiver MAY do instead is decode the public key out of the multibase suffix and verify your signature against it trust-on-first-use. That is a bootstrap key-supply path, not a resolution, so the key can never be rotated and a receiver that requires a resolvable counterparty will turn you away.
Move to did:web: when you want an identity a receiver can resolve, or to a key-derived tulpa: / ink: agentId when you want one that carries its own genesis key. Either one publishes an Agent Card and 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:
idandcorrelationIdare ULIDs, 26-char Crockford-base32 strings. Use the same value foridandcorrelationIdon a brand-new envelope; receivers thread replies bycorrelationId.timestampandnonceride alongside the canonical envelope fields. They are read by the receiver’s HTTP §3.3 freshness and replay check from the body rather than 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:
- Body-level Ed25519, the
signaturefield inside the envelope. Signs the JCS-canonical bytes of the envelope (withsignatureitself stripped) prefixed by a version-keyed domain separator:tulpa/sign\nfor ink/0.1 andink/sign\nfor ink/0.2. The verifier selects the separator from the signedprotocolfield. This binds the sender to the envelope content. - 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%3AzRecipient/intent", recipientDid: "tulpa:zRecipient", body, timestamp: body.timestamp, }, kp.privateKey,);const authHeader = buildAuthHeader(transportSig);Two details in that block are where from-scratch implementations usually go wrong. The body signature is computed over unsigned, the envelope without its signature member, while the transport signature is computed over body, the envelope with it: the transport base commits to the bytes you actually send and strips nothing. And path is the path component of the URL you post to, which is whatever the recipient published as endpoint in its Agent Card. Neither /ink/v1/tulpa%3AzRecipient/intent nor any other spelling is reserved by the protocol; read it from the card rather than from this page. Note the escaped colon: an agentId that appears in a path is percent-encoded as a single segment, and the signed path must be byte-identical to the path on the request line, escapes included. See Canonicalization and Authentication.
With the ink-interop CLI:
ink-interop send \ --from-did "$MY_DID" \ --to-did "tulpa:zRecipient" \ --target-url "https://api.tulpa.network/ink/v1/tulpa%3AzRecipient/intent" \ --intent-type connection_request \ --purpose "Hello, saw your profile and would like to connect." \ --seed ~/.ink/my-seed.hexThe CLI builds the envelope, applies both signatures, posts and prints the response.
Sending it
curl -X POST "https://api.tulpa.network/ink/v1/<recipientDid>/intent" \ -H "Content-Type: application/json" \ -H "Authorization: INK-Ed25519 <base64url-signature>" \ --data @envelope.jsonA 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 / body | What it means | Fix |
|---|---|---|
401 missing_timestamp | Envelope had no top-level timestamp field. | Add one. It’s separate from createdAt despite often being equal. |
401 timestamp_expired | The timestamp is more than 5 minutes old by the receiver’s clock. | Re-sign with current time. Check clock drift. |
401 signature_verification_failed | The 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_envelope | Schema 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_sender | You 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_sender | The 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, so 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:
- 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.
- 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). - 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.networkaccepts signedconnection_request,intro_request,pingandaskenvelopes fromdid:key:anddid:web:senders, and returns an acknowledgement. It reaches adid:web:sender’s keys by resolving its Agent Card, and adid:key:sender’s key by decoding the identifier inline, which is the bootstrap path above and not a resolution.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. A200ack means your bytes are on the canonical wire; a400withauth:...means the signature or freshness check failed. Its full source isexamples/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.
- Public test target: the reference receiver at
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, the authority rule and lifecycle you inherit once you move to a principal that publishes an Agent Card. A
did:key:sender has none of it: the embedded key is the only key it will ever have, and adopting a resolvable principal means adopting a new identity rather than rotating this one.