NFC Agent

Client API

Read scans and write tags over the agent's WebSocket, plus its REST health check.

The NFC Agent serves both roles from a single server on one port:

ServerPortPurpose
Agent Server9470Serves both NFC devices (hardware readers, smartphones, browsers) via /ws?mode=device and client applications via /ws
CA Bootstrap9472Serves TLS certificates for device setup

The agent server port is configurable via -device-port (default 9470).

Both listeners are plugins the program registers, so what an agent serves is decided by the build rather than fixed here: this page describes the shipped binary. See Custom Builds for the Go API behind it.

The agent provides NFC data to client applications on the same port as devices (plain /ws, without the ?mode=device query). This is the agent server port (default 9470, configurable via -device-port).

Connecting

Connect via WebSocket:

const ws = new WebSocket('ws://localhost:9470/ws');

With API secret:

const ws = new WebSocket('ws://localhost:9470/ws?secret=your-secret');

A client on the agent's own host presents the secret like any other. See The loopback bypass for the setting that exempts it.

Session Behavior

  • First connection claims the session (automatic lock)
  • Session released automatically on disconnect
  • Subsequent connections rejected with 409 Conflict until first disconnects

Messages from Server

Device Status

{
  "type": "deviceStatus",
  "payload": {
    "connected": true,
    "message": "Device connected",
    "cardPresent": false
  }
}

Tag Data

When a card is detected and read:

{
  "type": "tagData",
  "payload": {
    "uid": "04A1B2C3D4E5F6",
    "type": "MIFARE Classic 1K",
    "technology": "ISO14443A",
    "scannedAt": "2024-10-06T12:34:56Z",
    "deviceID": "dev_abc123",
    "capabilities": {
      "canRead": true,
      "canWrite": true,
      "canLock": true,
      "maxNdefSize": 716,
      "tagFamily": "MIFARE Classic",
      "supportsNdef": true
    },
    "message": {
      "type": "ndef",
      "records": [
        {
          "tnf": 1,
          "type": "text",
          "content": "Hello, NFC!",
          "language": "en",
          "payload": "AmVuSGVsbG8sIE5GQyE="
        }
      ]
    },
    "text": "Hello, NFC!",
    "err": null
  }
}

Payload Fields:

FieldDescription
uidCard unique identifier (hex string). For a non-NFC scan (a QR or barcode), the raw value the device reported, carried verbatim. See Non-NFC scans
typeCard type: MIFARE Classic 1K, MIFARE Classic 4K, MIFARE DESFire, MIFARE Ultralight, ISO14443-4 Type 4A (experimental). Free-form for a non-NFC scan (whatever the device reported)
technologyNFC technology standard (ISO14443A, ISO14443B, etc.), or whatever the device reported for a non-NFC scan
scannedAtISO 8601 timestamp
deviceIDThe paired device that scanned the tag. Omitted when the agent's own hardware reader read it. That is the only reader deviceStatus describes, so a client holding a tag can tell whether that status has anything to say about it
capabilitiesWhat the tag supports. See Tag Capabilities
messageStructured NDEF message data
textQuick access to first text record
errError message or null on success

NDEF Message Structure:

{
  "type": "ndef",
  "records": [
    {
      "tnf": 1,
      "type": "text",
      "content": "Decoded text",
      "language": "en",
      "payload": "AmVuRGVjb2RlZCB0ZXh0"
    }
  ]
}
  • tnf: Type Name Format (0x01 = Well Known)
  • type: record type, human-readable. One of text, uri, mime, smartposter, aar, external, and so on. Not the raw NFC Forum type byte
  • content: the record's decoded value, whatever its type. The text of a text record, the URI of a URI record. One field rather than one per type, since a record carries a single value and type beside it already says which kind. Omitted for a record with nothing decodable
  • language: language code, text records only
  • id: record ID, when the record carries one
  • payload: the raw record payload, base64-encoded. This is the record's bytes as they sit on the tag, not the decoded value. A text record's payload leads with a status byte and the language code, which is why it does not simply base64-decode to content

The write direction uses these same names (see Write Request), so a record read from one tag can be written back to another unchanged.

Messages to Server

All client messages support an optional id field for request/response correlation.

Write Request

Write NDEF data to a card (complete overwrite):

{
  "id": "req_1",
  "type": "writeRequest",
  "payload": {
    "records": [
      {
        "type": "text",
        "content": "Hello, NFC!",
        "language": "en"
      }
    ]
  }
}

Multiple records:

{
  "id": "req_2",
  "type": "writeRequest",
  "payload": {
    "records": [
      {
        "type": "text",
        "content": "Hello, NFC!",
        "language": "en"
      },
      {
        "type": "uri",
        "content": "https://example.com"
      }
    ]
  }
}

Record Fields:

FieldTypeRequiredDescription
typestringNoRecord type (see below). Defaults to text.
contentstringVariesPrimary value: text, URI, domain, package name, etc.
languagestringNoISO language code for text/smartposter (default: en)
mimeTypestringNoMedia type for mime records
titlestringNoDisplay title for smartposter records
payloadbytes (base64)NoRaw bytes for mime, vcard, external, raw
tnfnumberNoType Name Format (0–7) for raw records
typeBytesbytes (base64)NoNDEF type bytes for raw records
idbytes (base64)NoOptional record ID for raw records

Supported type values:

typeFields usedNotes
textcontent, languageDefault when type omitted
uri / urlcontentPrefix is auto-abbreviated to save tag space
mailto / email, tel, sms, geocontentURI shortcut; scheme prepended if absent
smartpostercontent (URI), title, language"Tap to open title": URI + label
mimemimeType, payload (or content)Arbitrary MIME media record
vcardcontent or payloadContact card (text/vcard MIME)
externalcontent (domain:type), payloadNFC Forum external type
aarcontent (package name)Android Application Record (app launch)
empty / erasenoneEmpty record: blanks/formats the tag (reversible)
rawtnf, typeBytes, id, payloadFully custom record

WiFi credentials can be written as a mime record with mimeType set to application/vnd.wfa.wsc and a WSC-formatted payload.

Write Response

Success:

{
  "id": "req_1",
  "type": "writeResponse",
  "success": true,
  "payload": {
    "message": "Write operation completed successfully",
    "uid": "04A1B2C3D4E5F6",
    "tagType": "MIFARE Ultralight",
    "bytesWritten": 28,
    "verified": true,
    "attempts": 1
  }
}

The agent confirms every write before reporting success: it checks the encoded message against the tag's capacity, retries transient failures, and reads the data back to verify it landed.

Success Payload Fields:

FieldTypeDescription
messagestringHuman-readable status
uidstringUID of the tag that was written
tagTypestringDetected tag type
bytesWrittennumberSize of the encoded NDEF message written
verifiedbooltrue when the write was confirmed by reading it back
attemptsnumberNumber of write attempts before success
lockedbooltrue when the tag was made read-only (see below)

A write that cannot be confirmed (verification mismatch after retries) returns an error response rather than a success: success: true means the data is on the tag. A response with verified: false only occurs if verification was explicitly disabled by the agent.

Raw Exchange (transceive)

Exchange raw bytes with the tag currently present. Command and response are base64 in transit, matching how the device protocol carries byte slices.

{
  "id": "req_3",
  "type": "transceiveRequest",
  "payload": {
    "data": "/8oAAAA=",
    "raw": false
  }
}
FieldTypeRequiredDescription
databytes (base64)YesCommand bytes to send
rawboolNoFraming-level exchange (NfcA.transceive, InCommunicateThru) instead of APDU-level (IsoDep.transceive, InDataExchange)

Response:

{
  "id": "req_3",
  "type": "transceiveResponse",
  "success": true,
  "payload": { "data": "BKKzxNXmgJAA" }
}

The request is routed like a write: to the remote device holding a tag when no hardware reader has a card present, otherwise to the reader.

A tag answering with an error status word is still success: true, because the exchange happened, and interpreting SW1SW2 is the caller's job. success is false only when the exchange itself could not be performed.

Gated behind the raw APDU channel. The channel that carries raw exchanges is off by default and refuses one with RAW_CHANNEL_DISABLED until an operator opens it — on the command line with -allow-raw-apdu, from the tray's Allow Raw APDU Channel toggle, or in the Control Center. A raw command reaches the tag unmodified and can burn OTP bits or lock a tag permanently, and the agent can neither recognise nor undo that, so opening the channel is a deliberate step.

Refused in read-only mode. The channel being open is not enough: the agent cannot tell a SELECT from a write to a configuration page, so a raw exchange is treated as a write and also refused with READ_ONLY while the reader is read-only. The mode is checked first, so its refusal is the one you see when both apply.

Accepts an optional deviceID. See Naming the tag.

Tag Capabilities

Every tagData broadcast includes a capabilities object describing what the present tag supports, so a client can gate its UI (show "lock"/"password" only when supported, render a capacity meter, etc.) without a round-trip.

{
  "canRead": true,
  "canWrite": true,
  "canTransceive": false,
  "canLock": true,
  "isReadOnly": false,
  "memorySize": 540,
  "maxNdefSize": 504,
  "technology": "ISO14443A",
  "tagFamily": "NTAG",
  "supportsNdef": true,
  "supportsPassword": true
}
FieldDescription
canRead / canWriteWhether read / write operations are supported
canTransceiveRaw APDU transceive supported
canLockTag can be made permanently read-only
isReadOnlyTag is already locked (omitted when false)
memorySizeTotal memory in bytes (omitted when unknown)
maxNdefSizeMaximum NDEF message size in bytes (omitted when unknown)
tagFamilyMIFARE Classic, DESFire, NTAG, MIFARE Ultralight, Type 4, …
supportsNdefTag supports NDEF
supportsPasswordTag supports simple password protection (NTAG21x PWD/PACK)

canWrite, canLock and canTransceive describe what the agent will actually do, not just what the tag is built for: they are reported false while the agent is in read-only mode, and, for a tag held by a remote device, false unless that device declared the operation and is still connected. A capability the agent would refuse is never advertised.

Query on demand: to fetch capabilities without waiting for the next scan, send a capabilitiesRequest:

{
  "id": "req_cap",
  "type": "capabilitiesRequest"
}

Response (type: "capabilitiesResponse"):

{
  "id": "req_cap",
  "type": "capabilitiesResponse",
  "success": true,
  "payload": {
    "capabilities": { "canWrite": true, "canLock": true, "supportsPassword": true, "maxNdefSize": 504 }
  }
}

The query is routed like a write: to the device holding a tag when no hardware reader has a card, otherwise to the reader. For a device-held tag it is answered from what the device declared at the scan, with no round trip, so it costs nothing to ask. Accepts an optional deviceID; see Naming the tag.

If nothing is holding a tag, success is false with NO_CARD.

Locking Tags (Make Read-Only)

Locking is irreversible: once a tag is made read-only it can never be written again. Only tags that support locking (e.g. NTAG, MIFARE Ultralight) can be locked; others return an error.

Write and lock in one step: add "lock": true to a write request:

{
  "id": "req_1",
  "type": "writeRequest",
  "payload": {
    "lock": true,
    "records": [{ "type": "uri", "content": "https://example.com" }]
  }
}

The write response then includes "locked": true.

Lock an already-written tag: send a lockRequest:

{
  "id": "req_9",
  "type": "lockRequest"
}

Response (type: "lockResponse"):

{
  "id": "req_9",
  "type": "lockResponse",
  "success": true,
  "payload": {
    "message": "Lock operation completed successfully",
    "uid": "04A1B2C3D4E5F6",
    "tagType": "MIFARE Ultralight",
    "locked": true
  }
}

If the present tag does not support locking, success is false with an error.

The request is routed like a write: to whichever source is holding the tag it names. A device receives it as a deviceWriteRequest with lock: true and no message.

Both take the uid of the tag they apply to, optionally a deviceID, and optionally an idempotencyKey. See Naming the tag. A lock cannot be undone, so it is refused rather than redirected when the tag named is not the tag present.

Refused in read-only mode. Locking is irreversible, so the agent's read-only mode refuses it with READ_ONLY on every route. A tag held by a phone included. Writes are refused the same way.

Naming the Tag

writeRequest, lockRequest, transceiveRequest and capabilitiesRequest all name the tag they apply to, with the uid from the tagData they are responding to:

{
  "id": "req_10",
  "type": "writeRequest",
  "payload": {
    "uid": "04A1B2C3D4E5F6",
    "records": [{ "type": "text", "content": "Hello" }]
  }
}

The agent finds whichever source is holding that tag, its own reader or a paired device, and refuses the request if none is. It does not matter which scanned most recently, or whether anything has been scanned since.

Naming the tag is what makes the target deterministic. Resolving instead by whichever source scanned most recently is evaluated when the request arrives, not when the tag was scanned, so a card lifted in between moves the write to a different tag: a payload encoded for one tag lands on another, irreversibly so when the request also locks.

A request whose tag is not present fails with NO_CARD and is never applied somewhere else. It is retryable: present the tag again and the same request works. If a tag is present but is not the one named, the failure is TAG_MISMATCH, which is not retryable. Re-read the tag instead, because the one now on the reader is a different tag with a different UID.

Naming a device instead. Every tagData carries the deviceID of the device that scanned it, and a request may name that instead of, or alongside, the uid:

{ "deviceID": "dev_abc123", "uid": "04A1B2C3D4E5F6" }

Naming a device is decisive: the request goes to that device or fails, never falling back to the reader, since a tag on the reader is a different tag. Giving both holds the device to the UID too, so a deviceID remembered from an earlier scan cannot act on whatever that device is holding now.

Naming neither. A request with no uid and no deviceID is refused with TAG_NOT_NAMED. A client that genuinely cannot name its tag may opt back into the old guess, per request:

{ "allowUntargeted": true, "records": [{ "type": "text", "content": "Hello" }] }

It is a request field rather than an agent setting so that one such client carries the risk itself, instead of the operator lowering the guarantee for every client on the agent. A request that does name a tag is still checked.

The bundled JavaScript client fills in uid from the last tag it saw, so client.write({ records }) is already targeted and needs no change.

The reader the operator picked. When a reader is selected in the console or the tray, the agent works with that one: its scans are the only ones sent, and its tag is the only one a request can reach. A request naming another reader, or a UID only another reader has seen, fails as though nothing were holding that tag, and allowUntargeted resolves among the selected reader alone. What a client is shown is what it can act on.

Devices that report their own scans, such as paired phones, are not affected: the operator picked which reader to work with, not which phone.

Idempotency. writeRequest and lockRequest also accept an idempotencyKey, passed through to the device. Reuse it when retrying after a lost response and a device that already applied it reports the previous outcome instead of writing again. Omitted, the request id is used, so reusing that on a retry has the same effect.

Password Protection (planned)

Password protection (NTAG PWD/PACK/AUTH0) is not yet available. The per-tag capability is reported (supportsPassword, true for NTAG21x) and the API contract below is fixed, but the destructive configuration writes are gated off pending validation on real hardware: a wrong AUTH0/ACCESS value can permanently lock a tag. Calls currently return a not-supported error.

Planned request shape (subject to change until enabled):

{
  "id": "req_10",
  "type": "passwordRequest",
  "payload": {
    "action": "set",            // "set" or "remove"
    "password": "01020304",     // hex, 4 bytes
    "protectRead": false,        // false = write-protect only
    "startPage": 4               // first protected page (AUTH0)
  }
}

Error:

{
  "id": "req_1",
  "type": "error",
  "success": false,
  "error": "Write failed: card removed",
  "payload": {
    "code": "WRITE_FAILED"
  }
}

Append Pattern

To append records, use read-modify-write:

// 1. Read current tag data
const currentData = await client.getLastTag();

// 2. Extract existing records
const existingRecords = currentData.message.records.map(r => ({
  type: r.type === 'T' ? 'text' : 'uri',
  content: r.text || r.uri,
  language: r.language || 'en'
}));

// 3. Write back with new record appended
socket.send(JSON.stringify({
  type: 'writeRequest',
  payload: {
    records: [...existingRecords, { type: 'text', content: 'New record' }]
  }
}));

The loopback bypass

A connection from the agent's own host presents the shared API secret or a token issued at pairing, like any other connection. Loopback identifies the host, so admitting it without a credential also admits other accounts on that host, local proxies, and port forwards into it.

-allow-loopback-bypass (or DAVI_NFC_ALLOW_LOOPBACK_BYPASS=1) admits loopback with no credential, for a local client that cannot be given the secret. It covers the shared secret only: under Requiring pairing a device connection still needs a paired credential. The console's control surface is unaffected either way, requiring loopback, its own origin and a session token.

The shipped console reads the secret from its session and sends it, so it needs nothing here.

REST API

Base URL: http://localhost:9470/api/v1

Health Check

GET /api/v1/health

curl http://localhost:9470/api/v1/health

Response:

{
  "status": "ok",
  "type": "agent"
}

Both /health and /api/v1/health are served on the agent server port and report "type": "agent". They are the agent's own routes, mounted on whatever listener the build registers, so they are there whatever else is. A build puts its own paths on the same port as endpoints of the server plugin, which is how the Control Center is served from it.