Vercel Logo

Drive It Over HTTP

The TUI (terminal user interface) is a comfortable place to test an agent, but no customer is ever going to open your terminal. A real dispatcher gets reached over the wire: from a web page, a Slack message, a phone. Before we wire up any of those surfaces, it's worth seeing the thing they all sit on top of.

Every eve app exposes the same HTTP session API, regardless of the interface built on top. The TUI and the dashboard are both clients of the routes that start sessions, stream events, and send follow-ups. Slack gets its own webhook route, but it drives the same durable sessions underneath.

So let's skip the surfaces for a second and talk to the dispatcher the way every surface does, with plain curl.

Outcome

You start a durable session with the dispatcher over HTTP, stream its reply as events, and send a follow-up to the same session.

Hands-on exercise

With npx eve dev running, open a second terminal. There's nothing to write this lesson, you're learning the contract every channel speaks, so we drive it by hand.

Start a session. Send the customer's first message:

curl -X POST http://127.0.0.1:2000/eve/v1/session \
  -H 'content-type: application/json' \
  -d '{"message":"what does a basic tune-up cost?"}'

The response should look like this:

{"ok":true,"sessionId":"wrun_01...","deliveryId":"delivery_01..."}

That's an HTTP 202 Accepted: eve took the message and started the turn in the background. It is not the dispatcher's answer. Copy the sessionId from this response; you use it to stream the answer and to address the next message to this conversation. The deliveryId names this one message, so a client can match it to the stream later.

If zsh prints % immediately after the closing brace, that is just your shell prompt touching curl's newline-free JSON output; it is not part of the response.

Accepted is a handoff, not the answer

The first request returns a 202 quickly with a session handle while the durable turn runs in the background. Seeing a sessionId and deliveryId with no reply text is expected. Attach to the stream with the returned sessionId to read what the dispatcher says.

Stream the session. Point at the stream route with the id from the JSON response. -N tells curl not to buffer the events:

curl -N http://127.0.0.1:2000/eve/v1/session/<sessionId>/stream

You get newline-delimited JSON. Each line is a commit, {"at": ..., "facts": [...]}, holding every fact one state change produced, or a progress record carrying streamed text as it's typed. The facts tell the whole story: the message arriving, the turn starting, the lookup_service call going out and settling, the finished reply text, and the turn settling. The TUI renders this same log as the ⚙ lines you saw earlier.

The stream stays open after the turn ends, sending an occasional {"$eve":"heartbeat"} line, because the session can take more messages. Press Ctrl-C when you've seen enough.

Send a follow-up. Once the stream shows turn.settled, post the next message to that same session ID:

curl -X POST http://127.0.0.1:2000/eve/v1/session/<sessionId> \
  -H 'content-type: application/json' \
  -d '{"message":"and how long does it take?"}'

The dispatcher retained the session history, so it already knows you're asking about the tune-up.

Try It

The first POST returns something like this:

{"ok":true,"sessionId":"wrun_01...","deliveryId":"delivery_01..."}

The raw commit lines are long, so let's read only the fact types. Pipe the stream through jq (install it with your package manager if you don't have it):

curl -sN http://127.0.0.1:2000/eve/v1/session/<sessionId>/stream \
  | jq -r '.facts[]?.type'
delivery.admitted
session.started
delivery.consumed
turn.started
model.requested
model.started
call.requested
call.started
call.settled
usage.recorded
model.settled
model.requested
model.started
content.completed
usage.recorded
model.settled
turn.settled
delivery.settled

Your list may run a little longer or shorter, since the model decides how many steps it needs. Read it top to bottom: the message is admitted, the turn starts, the model calls lookup_service (call.requested through call.settled), a second model run writes the reply (content.completed), and the turn settles. That turn.settled, with outcome: "completed", is the agent telling you it's done with this turn and ready for the next message. The same durable machinery will let a turn pause for human approval in Section 3 and pick up days later.

Send one turn at a time

For predictable ordering, wait for turn.settled before sending the next message to the same session. A message sent mid-turn steers the turn that's already running instead of starting a new one, which is rarely what you want while you're learning the contract.

Getting a 401? The dev server accepts local requests, but make sure you're hitting 127.0.0.1/localhost and not a deployed URL. Getting a 404 from the stream route, or a 409 with session_not_active from the follow-up route? Check that you copied the complete sessionId from the first response. A 409 with session_not_ready means you sent the follow-up before the session's inbox finished starting. That's expected right after the first POST; wait a second and send it again.

Done-When

  • POST /eve/v1/session returns 202 with a sessionId and deliveryId.
  • Streaming the session prints NDJSON commit lines, including call.requested for lookup_service and turn.settled.
  • A follow-up POST to that session ID continues the conversation without repeating context.
  • You can explain why the first POST returns a handle instead of the assistant's answer.

Solution

There's no code to write here. The agent exposed these routes when you ran it, and every channel uses the same contract:

RouteWhat It Does
POST /eve/v1/sessionStart a durable session; returns 202 with a sessionId and deliveryId.
GET /eve/v1/session/:id/streamStream the run as NDJSON events (reconnectable).
POST /eve/v1/session/:idSend a follow-up to the same durable session.

Hold onto this picture. In Section 4 you'll put a web dashboard and Slack in front of the dispatcher, and both drive the same durable sessions. useEveAgent calls these same routes from the browser. The Slack channel receives events on its own webhook route.

Was this helpful?

supported.