The NFC Agent serves both roles from a single server on one port:
| Server | Port | Purpose |
|---|---|---|
| Agent Server | 9470 | Serves both NFC devices (hardware readers, smartphones, browsers) via /ws?mode=device and client applications via /ws |
| CA Bootstrap | 9472 | Serves 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 Conflictuntil 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:
| Field | Description |
|---|---|
uid | Card 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 |
type | Card 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) |
technology | NFC technology standard (ISO14443A, ISO14443B, etc.), or whatever the device reported for a non-NFC scan |
scannedAt | ISO 8601 timestamp |
deviceID | The 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 |
capabilities | What the tag supports. See Tag Capabilities |
message | Structured NDEF message data |
text | Quick access to first text record |
err | Error 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 oftext,uri,mime,smartposter,aar,external, and so on. Not the raw NFC Forum type bytecontent: 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 andtypebeside it already says which kind. Omitted for a record with nothing decodablelanguage: language code, text records onlyid: record ID, when the record carries onepayload: 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 tocontent
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:
| Field | Type | Required | Description |
|---|---|---|---|
type | string | No | Record type (see below). Defaults to text. |
content | string | Varies | Primary value: text, URI, domain, package name, etc. |
language | string | No | ISO language code for text/smartposter (default: en) |
mimeType | string | No | Media type for mime records |
title | string | No | Display title for smartposter records |
payload | bytes (base64) | No | Raw bytes for mime, vcard, external, raw |
tnf | number | No | Type Name Format (0–7) for raw records |
typeBytes | bytes (base64) | No | NDEF type bytes for raw records |
id | bytes (base64) | No | Optional record ID for raw records |
Supported type values:
type | Fields used | Notes |
|---|---|---|
text | content, language | Default when type omitted |
uri / url | content | Prefix is auto-abbreviated to save tag space |
mailto / email, tel, sms, geo | content | URI shortcut; scheme prepended if absent |
smartposter | content (URI), title, language | "Tap to open title": URI + label |
mime | mimeType, payload (or content) | Arbitrary MIME media record |
vcard | content or payload | Contact card (text/vcard MIME) |
external | content (domain:type), payload | NFC Forum external type |
aar | content (package name) | Android Application Record (app launch) |
empty / erase | none | Empty record: blanks/formats the tag (reversible) |
raw | tnf, typeBytes, id, payload | Fully 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:
| Field | Type | Description |
|---|---|---|
message | string | Human-readable status |
uid | string | UID of the tag that was written |
tagType | string | Detected tag type |
bytesWritten | number | Size of the encoded NDEF message written |
verified | bool | true when the write was confirmed by reading it back |
attempts | number | Number of write attempts before success |
locked | bool | true 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
}
}
| Field | Type | Required | Description |
|---|---|---|---|
data | bytes (base64) | Yes | Command bytes to send |
raw | bool | No | Framing-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_DISABLEDuntil 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
SELECTfrom a write to a configuration page, so a raw exchange is treated as a write and also refused withREAD_ONLYwhile 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
}
| Field | Description |
|---|---|
canRead / canWrite | Whether read / write operations are supported |
canTransceive | Raw APDU transceive supported |
canLock | Tag can be made permanently read-only |
isReadOnly | Tag is already locked (omitted when false) |
memorySize | Total memory in bytes (omitted when unknown) |
maxNdefSize | Maximum NDEF message size in bytes (omitted when unknown) |
tagFamily | MIFARE Classic, DESFire, NTAG, MIFARE Ultralight, Type 4, … |
supportsNdef | Tag supports NDEF |
supportsPassword | Tag 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_ONLYon 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
uidfrom the last tag it saw, soclient.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.