Skip to content

REST API (v1)

3270Web exposes a small JSON HTTP API for non-browser clients (RPA bots, CI jobs, integration scripts). The API is versioned and gated by a Bearer token so it can be enabled per-deployment.

Enabling the API

Set the API_TOKEN environment variable to a non-empty secret before starting 3270Web. The simplest way is to add a line to the .env file that 3270Web reads on startup:

API_TOKEN=replace-with-a-long-random-string

When API_TOKEN is unset or empty, every /api/v1/* request returns 503 Service Unavailable with {"error": "API disabled: API_TOKEN not configured"}. This is the default so the API can't be accidentally exposed.

The 3270Web server binds to 127.0.0.1:3270 by default, so the API is only reachable from the local host. The Bearer token is additional defense-in-depth for any deployment that changes the bind address via WEBUI_BIND — including the Docker image, which sets WEBUI_BIND=0.0.0.0 so that published ports work at all. In a container, what the API is reachable from is decided by the port mapping, not the bind address.

Authentication

Every request must include an Authorization: Bearer <token> header. Bad or missing tokens get 401 Unauthorized.

curl -H "Authorization: Bearer $API_TOKEN" \
  http://127.0.0.1:3270/api/v1/sessions

On an instance with accounts

API_TOKEN is one shared credential, which is all there is to say about who is calling when there is one operator. Where accounts are enabled (AUTH_MODE=local) it is refused at startup, and clients present a token issued to an account instead:

3270Web token add alice "ci pipeline"

Such a token reaches exactly what its owner reaches. GET /api/v1/sessions lists that account's sessions, and naming somebody else's session in a path answers 404 — the same answer as an ID that does not exist. A token issued --read-only may only make GET, HEAD and OPTIONS requests; anything else answers 403.

See API tokens for issuing, scoping and revoking them.

Calling from a browser

A page on another origin cannot reach this surface unless its origin is named in EMBED_ORIGINS; see Embedding 3270Web. Credentials are never allowed on those cross-origin calls — the token in the Authorization header is the whole of the authentication, and a browser's own 3270Web session cookie is never involved.

Endpoints

Method Path Description
GET /api/v1/sessions List active sessions
POST /api/v1/sessions Start a new host session
DELETE /api/v1/sessions/:id Disconnect and remove a session
GET /api/v1/sessions/:id/screen Refresh and read the current screen
POST /api/v1/sessions/:id/key Send an AID or navigation key
POST /api/v1/sessions/:id/field Write text into a field
POST /api/v1/sessions/:id/submit Submit modified fields + send Enter (or another AID)
POST /api/v1/sessions/:id/profile Run a host compatibility probe and return the CompatibilityProfile JSON
GET /api/v1/sessions/:id/profile Return the cached CompatibilityProfile from the last probe
GET /api/v1/sessions/:id/query Ask the terminal about the connection itself
POST /api/v1/sessions/:id/snapshots Freeze the screen and keep it under a name
GET /api/v1/sessions/:id/snapshots List the snapshots this session holds, or read one with ?name=
DELETE /api/v1/sessions/:id/snapshots?name= Drop one snapshot
POST /api/v1/sessions/:id/snapshots/diff Compare two snapshots, or one against the live screen
GET /api/v1/sessions/:id/toggles Read the terminal's display toggles
POST /api/v1/sessions/:id/toggles Change one display toggle
GET /api/v1/sessions/:id/buffer Read a region out of the terminal's own buffer, as characters or as the host's code points
GET /api/v1/sessions/:id/buffer/field Report the field a position falls in, as the terminal's buffer describes it
POST /api/v1/sessions/:id/screen-trace Start recording every screen the terminal draws
GET /api/v1/sessions/:id/screen-trace Report the trace, or download it with ?download=1
DELETE /api/v1/sessions/:id/screen-trace Stop recording
POST /api/v1/sessions/:id/printer Bind a 3287 printer LU beside this session
GET /api/v1/sessions/:id/printer Report the printer and the jobs it has collected
DELETE /api/v1/sessions/:id/printer Stop the printer, keeping its jobs
GET /api/v1/sessions/:id/printer/jobs List the print jobs, or download one with ?name=
DELETE /api/v1/sessions/:id/printer/jobs?name= Drop one print job
POST /api/v1/sessions/:id/hllapi Drive the terminal with HLLAPI-shaped calls: function numbers, one-based positions, return codes
GET /api/v1/tasks List the Guided Business Task catalogue
POST /api/v1/tasks Add or replace a task
POST /api/v1/sessions/:id/tasks/run Run a task in a session and return the result
GET /api/v1/library Download the tasks and host presets as one portable document
POST /api/v1/library Import one, or ask what importing it would do
POST /api/v1/macros/translate Turn a macro file into a recording, with a report of what did not translate

POST /api/v1/sessions

Create and start a host session. Sample-app pseudo-hostnames (mock, demo, sampleapp:appN) are rejected by the API — those are reserved for the browser UI.

curl -X POST \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"host":"mainframe.example.com:23"}' \
  http://127.0.0.1:3270/api/v1/sessions

Response:

{ "id": "f1c5...", "host": "mainframe.example.com", "port": 23 }

GET /api/v1/sessions/:id/screen

Refreshes the screen and returns its full structure.

curl -H "Authorization: Bearer $API_TOKEN" \
  http://127.0.0.1:3270/api/v1/sessions/$ID/screen

Response:

{
  "width": 80,
  "height": 24,
  "text": "...screen contents...\n...\n",
  "formatted": true,
  "kbd_lock": "U",
  "cursor": { "row": 5, "col": 12 },
  "fields": [
    {
      "start_row": 1, "start_col": 0,
      "end_row": 1, "end_col": 19,
      "value": "USERID",
      "protected": true, "numeric": false, "hidden": false,
      "length": 20
    }
  ],
  "status": "U F P C(mainframe.example.com) I 4 24 80 5 12 0x0 0.000"
}

kbd_lock is "U" (unlocked), "L" (locked), or "E" (error). The field is omitted when 3270Web could not parse the status line.

POST /api/v1/sessions/:id/key

Send a single key. The key name follows the same vocabulary the Copilot side panel uses: Enter, PF1..PF24, PA1..PA3, Tab, BackTab, Clear, Reset, EraseEOF, EraseInput, Home, Up, Down, Left, Right.

curl -X POST \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"key":"PF3"}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/key

POST /api/v1/sessions/:id/field

Write text into the input field that contains (row, col). Coordinates are 0-indexed. Text containing CR, LF, or TAB is rejected.

curl -X POST \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"row":3,"col":10,"text":"USER01"}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/field

POST /api/v1/sessions/:id/submit

Submit any modified fields and send an AID key. The default AID is Enter; pass {"aid": "PF3"} to use a different key. The response includes the updated screen.

curl -X POST \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"aid":"Enter"}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/submit

DELETE /api/v1/sessions/:id

Disconnect from the host and remove the session.

curl -X DELETE \
  -H "Authorization: Bearer $API_TOKEN" \
  http://127.0.0.1:3270/api/v1/sessions/$ID

POST /api/v1/sessions/:id/profile

Probe the session's connected host and return a CompatibilityProfile JSON document. The schema is shared byte-for-byte with 3270Connect -profile output, so profiles from either tool can be diffed against each other. Body is optional.

curl -X POST \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"collect_raw": true}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/profile

Supported body fields: ind_file_probe, collect_raw, per_action_timeout_ms. See the Host Compatibility Profiler page for the full walkthrough and the Compatibility Profile Schema for the response shape.

GET /api/v1/sessions/:id/profile

Return the cached CompatibilityProfile from the last probe in this session. 404 Not Found if no probe has run.

curl -H "Authorization: Bearer $API_TOKEN" \
  http://127.0.0.1:3270/api/v1/sessions/$ID/profile

GET /api/v1/sessions/:id/query

The connection's own account of itself: negotiated telnet options, TLS state, terminal name, cursor, byte counts, the s3270 build actually running, and everything else the terminal knows about its link to the host.

None of it is on the screen, which is the point. A session that renders perfectly may still have failed to negotiate TN3270E, or bound a different LU from the one that was asked for, and this is the only place that shows. It is the difference between "the application looks fine" and "the connection is what we specified".

curl -H "Authorization: Bearer $API_TOKEN" \
  http://127.0.0.1:3270/api/v1/sessions/$ID/query
{
  "session": "1900afaa255b1f4d29257119f4e8f021",
  "queries": {
    "ConnectionState": "connected-3270",
    "Host": "host mvs01.example.com 992",
    "TerminalName": "IBM-3278-4-E",
    "ScreenSizeCurrent": "rows 24 columns 80",
    "TelnetHostOptions": "BINARY END OF RECORD",
    "Tls": "not secure",
    "LuName": "",
    "Version": "s3270 v4.5ga5"
  },
  "available": ["About", "Actions", "BindPluName", "..."]
}

available is every field name, in the terminal's own order. A field whose value is genuinely empty is still reported — "the LU name is blank" and "this build has no LU name to report" are different facts.

Add ?name= for one field. The match is case-insensitive and the reply echoes the canonical spelling:

curl -H "Authorization: Bearer $API_TOKEN" \
  "http://127.0.0.1:3270/api/v1/sessions/$ID/query?name=terminalname"
{ "session": "1900afaa…", "name": "TerminalName", "value": "IBM-3278-4-E" }

A name that is not in available gets 400 Bad Request and the list of names that would have worked. That refusal is deliberate rather than pedantic: the underlying terminal action does not reliably reject a keyword it does not know — it can block instead — and a blocked command costs the session, because the only way out of it is to restart the terminal process. So names are only ever taken from the terminal's own answer, never from the request.

409 Conflict if the session is not connected.

The browser reads the same thing through its session cookie at GET /host/query, which is what the Connection panel shows — one implementation, two doors. See Keyboard and Controls.

Screen snapshots

A snapshot is the screen frozen and kept under a name. Freezing matters because every other read goes to the live buffer: between deciding to capture a screen and finishing reading it, the host may have written over it, and what comes back is half of one screen and half of another. One command freezes the display, and everything read afterwards is of the same instant.

The point of freezing it is comparison. Capture the screen a flow is supposed to land on, run the flow again tomorrow, and ask what changed — the answer is a list of rows, which is the difference between a test that tells you it failed and one that tells you why.

curl -X POST -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" -d '{"name":"before"}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/snapshots
{
  "name": "before",
  "taken_at": "2026-03-04T10:15:22.184Z",
  "snapshot": {
    "rows": 24,
    "cols": 80,
    "status": "U F P C(mvs01.example.com) I 4 24 80 5 20 0x0 0.012",
    "text": "                    ACCOUNT ENQUIRY\n..."
  }
}

Then run the transaction and ask what moved. Omitting right compares against the screen as it stands now, which is the shape this is usually used in:

curl -X POST -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" -d '{"left":"before"}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/snapshots/diff
{
  "left": "before",
  "right": "(live screen)",
  "identical": false,
  "lines": [
    { "row": 6, "left": " LAST NAME . . . .", "right": " LAST NAME . . . .  SMITH" },
    { "row": 11, "left": "", "right": " FIRST NAME FIELD IS REQUIRED." }
  ]
}

Rows are numbered from 1, the way an operator counts lines on a screen. Trailing blanks are ignored, so a one-character change reports one row rather than every row whose padding differs. A screen that grew or shrank reports its extra rows against an empty string rather than matching on the common prefix.

Names may contain letters, digits, spaces, dots, dashes and underscores. Capturing over a name that already exists replaces it — re-capturing before at the top of a loop is the ordinary way to use this. A session holds up to 32 snapshots, and they end when the session does: a snapshot is a working note taken during a run, not a record.

409 Conflict if the session is not connected, or if the ceiling is reached.

GET /api/v1/sessions/:id/buffer

Every other screen-reading endpoint here serves the parsed screen: the terminal is asked for the display, 3270Web turns it into a buffer and a list of fields, and the response is cut out of that. This one serves the terminal's own buffer instead, and it exists for the single question the parsed screen cannot answer — whether the parse is what went wrong.

That question arrives as a code page complaint, and it always looks the same: a pound sign showing as a hash, an umlaut taking two cells, a field reported one column wider than the application drew it. What settles it is the byte the host actually transmitted, and only the terminal has that.

row, col and length are required. Positions are one-based, as everywhere else on this API — row 1, column 1 is the top-left cell.

curl -H "Authorization: Bearer $API_TOKEN" \
  "http://127.0.0.1:3270/api/v1/sessions/$ID/buffer?row=6&col=21&length=12"
{ "row": 6, "col": 21, "length": 12, "encoding": "ascii", "text": "SMITH       " }

?encoding=ebcdic answers with the host's own code points instead, one hex byte per cell, in reading order:

curl -H "Authorization: Bearer $API_TOKEN" \
  "http://127.0.0.1:3270/api/v1/sessions/$ID/buffer?row=6&col=21&length=5&encoding=ebcdic"
{
  "row": 6, "col": 21, "length": 5, "encoding": "ebcdic",
  "codes": ["e2", "d4", "c9", "e3", "c8"]
}

That is the comparison worth making. If the codes are what the application says it sent and the characters are wrong, the code page is wrong — see Terminal Capabilities and the codePage setting on the connection profile. If the codes themselves are not what the host was asked to send, the problem is upstream of this terminal entirely.

A region that runs past the end of a row continues on the next one. The 3270 display is a single buffer that happens to be drawn as a rectangle, and a field is entitled to straddle the right-hand edge; a wrapped region comes back with the rows separated by newlines.

Cells inside a hidden field are masked — * in the text, ** in the code list — for the same reason they are masked on /screen: 3270 "hidden" only suppresses local echo on a real terminal, so the characters an operator typed into a password field are still in the buffer. Reading the buffer directly is not a way around that.

400 if the position or length is not a position or length the screen has, or if the terminal refuses the read — the refusal carries the terminal's own words. 409 if the session is not connected, or if there is no parsed screen to check the region against.

GET /api/v1/sessions/:id/buffer/field

The field a position falls in, as the terminal's buffer describes it rather than as 3270Web reconstructed it. row and col are required and one-based.

curl -H "Authorization: Bearer $API_TOKEN" \
  "http://127.0.0.1:3270/api/v1/sessions/$ID/buffer/field?row=6&col=25"
{
  "row": 6, "col": 21, "length": 20,
  "attribute": "c0",
  "protected": false, "numeric": false, "hidden": false,
  "intensified": false, "modified": true, "formatted": true,
  "text": "SMITH               ",
  "codes": ["e2", "d4", "c9", "e3", "c8", "40", "…"]
}

row and col are the field's first content cell. A 3270 field attribute occupies a buffer position of its own, so the attribute for the field above sits at column 20 and the text starts at 21 — the off-by-one every 3270 field calculation gets wrong at least once, and the reason this endpoint reports the content start rather than the attribute's.

attribute is the field attribute byte in hex, and the flags beside it are that byte decoded. It is the answer to "does the application think this field is numeric" and "is this field really protected", asked of the terminal rather than of anything downstream of it.

formatted is false on a screen carrying no field structure at all. An unformatted 3270 screen has no fields, so the whole buffer is reported as one unprotected field, which is what an unformatted screen is.

codes is the host's own EBCDIC code point for each content cell. It is absent on a terminal build that will not report them — rather than filled in from the display's code points, since the difference between those two is the whole question this endpoint exists to answer.

A hidden field reports its extent and its attributes, and * in place of its contents. Where it is and how wide it is disclose nothing; what was typed into it does.

GET and POST /api/v1/sessions/:id/toggles

The terminal's own display settings — monocase, the crosshair, cursor blink, the underscore under input fields. They are settings of the terminal, so they are read from and written to it rather than stored here; what you read is what the terminal will actually do.

curl -H "Authorization: Bearer $API_TOKEN" \
  http://127.0.0.1:3270/api/v1/sessions/$ID/toggles
{
  "session": "1900afaa…",
  "toggles": [
    { "name": "blankFill", "value": true, "description": "treat trailing blanks in a field as if they were nulls" },
    { "name": "monoCase", "value": false, "description": "display all letters in upper case" }
  ]
}

Only what this terminal build actually reports is listed. A toggle that is absent is absent, not false — an older or newer build not having one is not the same as it being off, and s3270 legitimately has fewer of them than a windowed terminal does.

curl -X POST -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" -d '{"name":"monoCase","value":true}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/toggles

The reply carries the toggle as the terminal reports it afterwards, not as the request asked for it, so a build that silently declines a change is visible in the answer.

value is required and must be true or false. A body without it would otherwise be indistinguishable from asking for the toggle to be turned off.

The settable names are an allowlist, and a name outside it gets 400 Bad Request together with the names that would have worked. The underlying terminal action also reaches trace files, printer sessions and the proxy configuration, none of which belong on an HTTP endpoint — so only display toggles can be named at all, and every name that reaches the terminal is one the terminal itself just reported.

The browser reads and writes the same thing through its session cookie at GET and POST /host/toggles.

/api/v1/sessions/:id/screen-trace

Records every screen the terminal draws, as it draws it — including the ones replaced before anybody asked to see them. That is what separates it from polling: a host that paints an error and immediately paints over it leaves a poller nothing to find, and that is usually the screen someone needs afterwards.

Requires ALLOW_SCREEN_TRACE=1 as well as the API token. A trace is a file on the server holding everything that crossed the display, including whatever was typed into a field the host did not mark hidden, so it is off until somebody turns it on. Without the flag, the start call returns 403.

curl -X POST -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" -d '{"format":"text"}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/screen-trace
{
  "session": "1900afaa…",
  "file": "1900afaa…-20260304T101522Z.txt",
  "format": "text",
  "started_at": "2026-03-04T10:15:22.184Z",
  "running": true
}

format is text (the default) or html, which preserves colour and highlighting. Files are written to screen-traces/ beside the executable, next to chaos-runs/. The destination is chosen by the server — no part of it comes from the request — and only a file this server started is ever served back.

GET reports the trace; ?download=1 returns the file itself, as an attachment, up to 16 MB. An HTML trace is a page built out of whatever the host painted, so it is never served inline. DELETE stops the capture; stopping one that was never started succeeds, so a cleanup path does not have to know whether it is cleaning up.

One trace per session: the terminal has a single trace destination, so a second concurrent start would silently redirect the first and is refused with 409.

Ending the session drops this server's record of where the trace went. The file stays on disk for whoever asked for it.

/api/v1/sessions/:id/printer

Binds a 3287 printer LU alongside the terminal session, and collects what the host prints to it. Batch output does not come back on the display: the host binds a separate printer LU and sends the job there, and something has to be listening.

On the token surface as well as the browser's because the case for it is unattended — a job runs overnight and something collects it in the morning. Printer sessions is the full page; this is the API shape.

curl -X POST -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" -d '{"lu":"PRT001","mpp":132}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/printer

Body fields, all optional: lu names a printer LU, or associate: true requests the printer paired with this session's own display LU (the terminal is asked what that is; the caller does not supply it). Then code_page, mpp (40–256), eoj_timeout, crlf, blank_lines, ff_skip, ff_thru, ignore_eoj and skip_cc. A body with neither lu nor associate binds by association.

The host, the port and the TLS terms are not settable: a printer session always follows the terminal session it belongs to. An endpoint that took a hostname would be a way to make the server open connections wherever it can reach, and the real case is always the printer LU on the mainframe the caller is already connected to.

GET returns available (whether this installation has a pr3287 binary at all), running, the printer itself, and the jobs collected so far:

{
  "available": true,
  "running": true,
  "printer": {
    "session": "1900afaa…",
    "host": "mainframe.example",
    "port": 992,
    "lu": "PRT001",
    "tls": true,
    "started_at": "2026-08-10T09:14:02Z",
    "running": true
  },
  "jobs": [
    {"name": "job-20260810T091455Z-3fa1c209.prt", "bytes": 18422,
     "received_at": "2026-08-10T09:14:55Z", "truncated": false}
  ]
}

GET /printer/jobs lists them; the same path with ?name= returns one job as an attachment. The name is checked against the shape this server generates, so nothing else under the spool directory or outside it is reachable. DELETE /printer/jobs?name= drops one.

Without a pr3287 binary the start call returns 501 naming what to install. A printer that will not bind — an unknown LU, an LU somebody else holds — returns 502 carrying what pr3287 said, because that sentence is usually the whole diagnosis.

One printer per session; a second start is refused with 409. Stopping the printer keeps its jobs. Ending the session deletes them, along with the spool directory: the jobs belong to the session that collected them.

POST /api/v1/sessions/:id/hllapi

A compatibility surface for screen-scrapers written against HLLAPI.

There is a lot of working code that drives a terminal by calling numbered functions with a presentation-space position and branching on a return code. Porting it to the endpoints above is not hard, but it is a rewrite of every call site — and the reason those programs still exist is that nobody has time to rewrite them. This endpoint lets one be ported by changing how it calls rather than what it does.

What it reproduces is the shape, because the shape is what the calling code is built around:

  • Positions are one-based and linear. Position 81 on a 24x80 screen is row 2, column 1. Every HLLAPI program does this arithmetic already.
  • Every call answers with a return code. The response is always HTTP 200 with an rc in the body — turning "string not found" into an HTTP error would make it a different program.
  • SendKey takes text with mnemonics embedded, so "SMITH@E" still means what it always meant.
curl -X POST -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"function":6,"text":"First Name"}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/hllapi
{ "rc": 0, "function": "SearchPresentationSpace", "number": 6, "position": 322 }

Functions

# Name Reads Answers
1 ConnectPresentationSpace rc 0. A no-op: the session is named in the path
2 DisconnectPresentationSpace rc 0. Also a no-op; DELETE /sessions/:id ends a session
3 SendKey text Types the literal runs and presses the mnemonics, in order
4 Wait rc 0 when the keyboard is unlocked, 4 while the host is still working
5 CopyPresentationSpace data — the whole screen, plus rows and cols
6 SearchPresentationSpace text, position position of the match, or rc 24
7 QueryCursorLocation position, and row/col one-based
8 CopyPresentationSpaceToString position, length data
15 CopyStringToPresentationSpace position, text Writes at that position
31 FindFieldPosition position position and length of the field there
32 FindFieldLength position length
33 CopyStringToField position, text Writes from the start of the field the position falls in
34 CopyFieldToString position data — the field's contents
40 SetCursor position Moves the cursor

function accepts the number or the name — the number for a mechanical port, the name for whoever reads it afterwards.

A function that is not implemented is refused with rc 25 and the list of those that are. Answering 0 for something that did not happen is the one thing a compatibility layer must never do, so nothing is guessed at.

A hidden field read through function 34 comes back redacted, with "hidden": true — the same treatment it gets everywhere else this application hands a screen out. This endpoint is not a way around that.

Return codes

rc Meaning
0 The call did what was asked
1 There is no presentation space — the session is not connected
2 A parameter was missing, malformed or untranslatable
4 The keyboard is locked; the host has not finished
7 The position is outside the presentation space
9 The terminal could not carry the call out
24 The string was not found
25 This function is not implemented here

Mnemonics

Mnemonic Key Mnemonic Key
@E Enter @1@9 PF1PF9
@C Clear @a @b @c PF10 PF11 PF12
@T Tab @x @y @z PA1 PA2 PA3
@B Back-tab @@ A literal @
@R Reset
@F Erase EOF

This is a subset on purpose. The mnemonic tables diverge between vendors past this common core, and a mnemonic mapped to the wrong key is worse than one that is not mapped at all: the wrong key reaches the host and the program carries on believing it did what it asked. Anything outside the table is refused with rc 2, and a key it does not cover can be named instead — {"function":3,"text":"PF13"} — since the rest of this application already understands key names.

A worked port

Typing a name into two fields and pressing Enter:

post() { curl -s -X POST -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" -d "$1" \
  http://127.0.0.1:3270/api/v1/sessions/$ID/hllapi; }

post '{"function":6,"text":"First Name"}'                  # find the prompt
post '{"function":33,"position":341,"text":"GRACE"}'       # fill its field
post '{"function":33,"position":421,"text":"HOPPER"}'
post '{"function":3,"text":"@E"}'                          # press Enter
post '{"function":5}'                                      # read the result

GET /api/v1/tasks and POST /api/v1/tasks

The Guided Business Task catalogue. GET returns exactly the documents POST accepts, so these two are also export and import — moving a catalogue between deployments, or keeping one in version control, needs no separate format. Every task goes through the same validation the browser and the runner use.

The catalogue is deployment-wide, which is why it is not under /sessions/:id.

curl -H "Authorization: Bearer $API_TOKEN" \
  http://127.0.0.1:3270/api/v1/tasks | jq '.tasks'

POST /api/v1/sessions/:id/tasks/run

Run a task and return its result. The task name travels in the body rather than the path: task names are prose, and one containing a slash would silently become two path segments.

curl -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"Account balance enquiry","parameters":{"account_number":"40218855"}}' \
  http://127.0.0.1:3270/api/v1/sessions/$ID/tasks/run
{
  "task": "Account balance enquiry",
  "durationMs": 152,
  "completed": true,
  "steps": [{ "index": 1, "description": "Enter the account number", "status": "ok" }],
  "outputs": [{ "name": "cleared_balance", "label": "Cleared balance",
                "value": "1,240.55", "found": true }]
}

Synchronous, unlike the browser's /tasks/run: a bot wants the answer in the response rather than a poll loop. Bounded at five minutes. One run per session, shared with the browser path, so an API run and a browser run cannot overlap on the same terminal.

A task that stops early returns 200 with completed: false plus the failure detail — which step, what it expected, what it found, and the screen it stopped on. The request succeeded; the body says what the host did. An HTTP status cannot express "step 3 saw the wrong screen".

Status Meaning
200 The run finished. Check completed.
400 A parameter was rejected. Nothing was sent to the host.
404 No such session, or no such task.
409 The session is not connected, or a run is already in progress on it.

GET /api/v1/library and POST /api/v1/library

A library is this deployment's Guided Business Tasks and connection profiles as one document, for carrying a set-up to another deployment. GET returns exactly what POST accepts, so the file downloaded from one instance is the body posted to the next.

curl -H "Authorization: Bearer $API_TOKEN" \
  http://test.internal:3270/api/v1/library > library.json

curl -H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \
  --data-binary @library.json \
  'http://prod.internal:3270/api/v1/library?dryRun=true'

The document:

{
  "formatVersion": 1,
  "exportedAt": "2026-04-02T09:14:00Z",
  "instance": "test.internal:3270",
  "tasks": [  ],
  "profiles": [  ],
  "notes": ["Host presets were read from the published list every account connects through."]
}

Query parameters

Parameter Where Meaning
include GET tasks, profiles, or both (the default)
download GET 1 to be offered as a file rather than shown
onConflict POST skip (default) leaves an existing name alone; replace overwrites it
dryRun POST true reports what would happen and writes nothing

Three rules worth knowing before pointing this at a production instance.

Nothing is written until everything validates. A library holding one entry this build refuses is refused whole, with that entry named, rather than half stored. The refusal is a 400 carrying the same report a success carries.

A library covers the set you administer. Tasks are always your own catalogue. Host presets are the published list for an administrator — and for the single operator of an instance with no accounts — and your own presets for anybody else. The report says which under profileScope.

It is not a backup. Audiences that name individual accounts are dropped on export: those accounts exist only on the deployment the file came from, and a file meant to be handed to another site should not carry a staff list. Groups and roles survive, since those are names two deployments plausibly share. A preset whose only audience was named accounts arrives not offered, so losing the restriction cannot silently turn "these four people" into "everyone". Tasks contributed by an installed extension are left out too — install the extension at the far end instead. Every one of these is recorded in the document's own notes.

The report:

{
  "dryRun": true,
  "profileScope": "published",
  "added": 12, "replaced": 0, "skipped": 1, "rejected": 0,
  "entries": [
    { "kind": "profile", "name": "Production TSO", "action": "added" },
    { "kind": "task", "name": "Account balance enquiry", "action": "skipped",
      "reason": "a task with this name is already here" }
  ],
  "notes": ["Host presets go into the published list every account connects through."]
}

An administrator who would rather not use curl has the same thing on Session screen → Library: download, choose a file, read what it would change, import.

POST /api/v1/macros/translate

Reads a macro file written for an installed terminal emulator and returns the recording it becomes, together with a line-by-line account of what it would not translate. It needs no session and touches none: it reads text and returns JSON, which is what converting a directory of macros in one pass needs.

Send the file as an upload in a macro field, or as JSON:

curl -H "Authorization: Bearer $API_TOKEN" \
  -F macro=@account-inquiry.mac \
  http://3270web.internal:3270/api/v1/macros/translate
{
  "name": "account-inquiry.json",
  "stepTotal": 9,
  "statements": 11,
  "translated": 9,
  "notes": [
    {
      "line": 8,
      "source": "If Session.Screen.Search(\"READY\") Then",
      "reason": "its condition is not something a recording can test: an If or a While compares one place on the screen, named by row, column and length, or one variable this file has already set, against a literal value — and the 2 statements inside it were left out with it, because a step inside a branch that did not translate would otherwise run every time"
    }
  ],
  "advisories": ["Wait times were read as milliseconds. …"],
  "workflow": { "Host": "", "Port": 0, "Name": "account-inquiry.json", "Steps": [  ] }
}

workflow is a recording in the schema on this page — write it to a file and it plays like any other. Host and Port are left empty: nothing here knows where you intend to replay it, and a recording that names a host it invented is worse than one that names none.

Read notes before replaying anything. They are the lines that were left out, each with the reason, and a run that ignores them is a flow with holes in it. A note against a branch is the largest hole of all: a branch whose condition did not translate takes the statements inside it too, and the note says how many. What produces a note, and why, is in Recordings and Playback; the same translation and the same report are one click away in the browser.

Converting a directory, keeping the report beside each file:

for f in macros/*.mac; do
  curl -sS -H "Authorization: Bearer $API_TOKEN" -F "macro=@$f" \
    http://3270web.internal:3270/api/v1/macros/translate > "out/$(basename "$f").json"
done
jq -r 'select(.notes | length > 0) | "\(.name): \(.notes | length) line(s) need a person"' out/*.json

A file no larger than 512 KB, and a body that is neither an upload nor {"name", "content"} JSON, is a 400.

Browser-session endpoints

These endpoints reuse the session cookie set by the connect flow rather than API_TOKEN. They are useful for in-browser callers and for tools that already drive 3270Web through the cookie.

Method Path Description
POST /profile Probe the current session and return the CompatibilityProfile JSON.
GET /profile Return the cached profile for the current session.
GET /printer/status The session's printer session and the jobs it has collected.
POST /printer/start Bind a printer LU. Same body as the API route; see Printer sessions.
POST /printer/stop Stop the printer, keeping its jobs.
GET /printer/jobs List the print jobs.
GET /printer/jobs/download?name= Download one job as an attachment.
POST /printer/jobs/delete?name= Drop one job.
GET /workflow/status Recording and playback state for the current session: the step a run is on, its recent events, and playbackVariables — what a recording that makes decisions has read so far. See Recordings and Playback.
POST /workflow/import-macro Translate an uploaded macro file and load the result as this session's recording. Returns the same report as the API route, without the recording itself. See Recordings and Playback.
POST /chaos/report Markdown discovery report for the active chaos run (ASCII screen graph, per-screen stats, suggested experiments).
POST /chaos/mindmap/compare Diff two previously-exported chaos mind maps. JSON by default; pass Accept: text/html (or ?format=html) for the HTML report. See Chaos Mind-Map Compare.
GET /chaos/screens Every screen discovered by chaos: fields, learned values, key destinations, business annotations, and a truncated preview. ?include_previews=false omits previews.
POST /chaos/screens/annotate Record a screen's business purpose and field semantics. Body: {"screen_hash", "business_purpose", "notes", "field_semantics": {"R5C20L8": {"name", "description", "example", "sensitive"}}}.
GET /chaos/business/functions List cataloged business functions (name, description, steps, parameters).
POST /chaos/business/functions Upsert a business function. Body: {"name", "description", "entry_screen_hash", "steps": [{"screen_hash", "inputs": [{"field_key", "value", "parameter"}], "aid_key", "expect_hash"}], "parameters": [{"name", "description", "screen_hash", "field_key", "example", "required"}]}.
POST /chaos/business/generate-workflow Generate a business-focused workflow JSON from a cataloged function. Body: {"name", "parameters": {"param": "value"}, "host", "port"}. Returns a playback-compatible workflow document with Name/Description/BusinessFunction/Parameters metadata.

AI provider

These endpoints configure which AI service AI Chat Mode talks to. Like the browser-session endpoints above, they use a cookie rather than API_TOKEN — settings are scoped to one browser identity (3270Web_copilot_id), so a shared instance keeps each person's credentials to themselves. See AI Providers for the concepts.

Method Path Description
GET /api/ai/providers The provider catalogue (id, label, auth style, default endpoint, fallback model list) plus the caller's saved settings.
GET /api/ai/status Whether the selected provider can answer a chat: {"provider", "providerLabel", "auth", "ready", "needs", "model", "baseUrl"}. needs is login (Copilot sign-in required), config (key or endpoint missing), or empty.
GET /api/ai/config Saved settings, per provider: {"provider", "providers": {"<id>": {"baseUrl", "model", "hasKey"}}}. API keys are never returned — only hasKey.
POST /api/ai/config Save settings. Body: {"provider", "target", "baseUrl", "apiKey", "model"}. Omitted fields keep their stored value, so a model change does not clear the key; an explicit "apiKey": "" clears it. target names the provider the other fields apply to (defaults to provider).
POST /api/ai/forget Clear the credentials for one provider. Body: {"provider"} (defaults to the selected one). For Copilot this also performs the OAuth logout.
GET /api/ai/models Model IDs available from the selected provider, fetched live where possible and falling back to the built-in list otherwise.
GET /api/ai/tools The tool schema and system prompt handed to the model. Identical for every provider — the tools describe the 3270 session, not the backend.
POST /api/ai/chat Streaming chat proxy. Takes an OpenAI-shaped /chat/completions body and returns OpenAI-shaped SSE chunks, whichever provider is selected — Anthropic's Messages protocol is translated in both directions.
# Point AI Chat at a local Ollama and confirm it is usable
curl -X POST http://127.0.0.1:3270/api/ai/config \
  -H 'Content-Type: application/json' \
  -d '{"provider":"ollama","baseUrl":"http://localhost:11434/v1","model":"qwen3"}'

curl http://127.0.0.1:3270/api/ai/status

The GitHub-specific /api/copilot/* routes (status, login/start, login/poll, logout, enterprise) are unchanged and still drive the OAuth device flow.

System endpoints

Unauthenticated and not session-scoped — intended for liveness/readiness probes.

Method Path Description
GET /healthz Returns 200 OK with {"status":"ok","version":"<app version>"}. Used by the Docker HEALTHCHECK and orchestrators. Performs no session or s3270 work. See Install and Run.

Errors

Status Meaning
400 Bad Request Bad input — e.g. missing host, CR/LF/TAB in field text
401 Unauthorized Missing or bad Authorization header
404 Not Found Session id does not exist
502 Bad Gateway The host or s3270 subprocess returned an error
503 Service Unavailable API_TOKEN not configured — API is disabled

Out of scope (v1)

The following are deliberately not part of v1. Some of them are tracked in the Feature Roadmap.

  • WebSocket / SSE streaming of screen changes
  • File transfer endpoints (IND$FILE)
  • OAuth / OIDC / SAML auth
  • Multiple tokens, per-token scopes, rate limiting
  • API token rotation

MCP

The MCP Server is built on this API. 3270Web mcp speaks the Model Context Protocol on stdin and stdout and calls these routes underneath, and the running server also exposes MCP over HTTP at POST /api/v1/mcp behind the same API_TOKEN.

The session-scoped routes above exist largely for it: naming the session in the path is what lets a client with no browser cookie drive chaos exploration and business understanding.