Skip to main content
Channels are Nvim’s way of communicating with external processes. They provide the foundation for remote plugins, UI clients, job control, and any external process integration.

Channel Types

Nvim supports several types of channels for different use cases:

RPC Channels

MessagePack-RPC protocol for API calls and events

Job Channels

Communicate with spawned processes via stdio

Socket Channels

TCP/IP or Unix domain socket connections

Stdio Channels

Standard input/output in headless mode

Opening Channels

There are several ways to open a channel in Nvim:

1. Stdio Channel

Use stdioopen() when running Nvim in headless mode:
" In headless mode (nvim --headless)
function! OnEvent(id, data, event)
  if a:data == [""]
    quit
  endif
  " Echo input back in uppercase
  call chansend(a:id, map(a:data, {i,v -> toupper(v)}))
endfunction

call stdioopen({'on_stdin': 'OnEvent'})
Run this example:
nvim --headless --cmd "source uppercase.vim"

2. Job Channel

Use jobstart() to spawn a process and communicate via stdin/stdout:
" Start a job and send data to it
let job_id = jobstart(['python3', '-u', 'script.py'], {
  \ 'on_stdout': {id, data, event -> s:OnOutput(data)},
  \ 'on_stderr': {id, data, event -> s:OnError(data)},
  \ 'on_exit': {id, code, event -> s:OnExit(code)}
  \ })

" Send data to the job
call chansend(job_id, "Hello from Nvim\n")

function! s:OnOutput(data)
  echo 'Output: ' . join(a:data, '')
endfunction
Lua equivalent:
local job_id = vim.fn.jobstart({'python3', '-u', 'script.py'}, {
  on_stdout = function(chan_id, data, name)
    print('Output:', vim.inspect(data))
  end,
  on_stderr = function(chan_id, data, name)
    print('Error:', vim.inspect(data))
  end,
  on_exit = function(chan_id, exit_code, event)
    print('Exited with code:', exit_code)
  end
})

vim.fn.chansend(job_id, 'Hello from Nvim\n')

3. Socket Channel

Use sockconnect() to connect to a TCP/IP or Unix socket:
" Connect to TCP socket
let chan_id = sockconnect('tcp', '127.0.0.1:6666', {'rpc': v:true})

" Connect to Unix socket
let chan_id = sockconnect('unix', '/tmp/nvim.sock', {'rpc': v:true})

" Send RPC request
let result = rpcrequest(chan_id, 'nvim_eval', '2 + 2')
echo result  " => 4

4. Listening Socket

Start a server that listens for connections:
" Start listening on a TCP port
call serverstart('127.0.0.1:6666')

" Start listening on a Unix socket
call serverstart('/tmp/my-nvim.sock')

" Get the default server address
echo v:servername

Channel Modes

Channels operate in different modes:

Raw Bytes Mode

Default mode for job channels. Data is sent and received as raw bytes:
let job = jobstart(['cat'], {
  \ 'on_stdout': {id, data, event -> s:HandleData(data)}
  \ })

call chansend(job, "raw bytes\n")

RPC Mode

Using MessagePack-RPC protocol for API calls:
" Job with RPC enabled
let nvim_job = jobstart(['nvim', '--embed'], {'rpc': v:true})

" Make RPC calls
let buffers = rpcrequest(nvim_job, 'nvim_list_bufs')
echo buffers

" Send notification (doesn't wait for response)
call rpcnotify(nvim_job, 'nvim_command', 'echo "Hello"')

PTY Mode

Pseudo-terminal for interactive programs:
let term_job = jobstart(['bash'], {
  \ 'pty': v:true,
  \ 'on_stdout': {id, data, event -> s:HandleTermData(data)}
  \ })

" Resize the PTY
call jobresize(term_job, 80, 24)

Channel Callbacks

1

Register callbacks

Callbacks are invoked when data arrives on the channel.
let s:channel_id = jobstart(['python3', 'script.py'], {
  \ 'on_stdout': function('s:OnStdout'),
  \ 'on_stderr': function('s:OnStderr'),
  \ 'on_exit': function('s:OnExit')
  \ })
2

Handle streaming data

Data arrives in chunks, first and last items may be partial lines.
let s:lines = ['']

function! s:OnStdout(chan_id, data, event)
  let eof = (a:data == [''])
  
  " Complete the previous line
  let s:lines[-1] .= a:data[0]
  
  " Append new lines (last may be partial)
  call extend(s:lines, a:data[1:])
  
  if eof
    " Process complete output
    call s:ProcessLines(s:lines)
  endif
endfunction
3

Use buffered mode for complete output

Set stdout_buffered to receive all output at once.
let job = jobstart(['grep', '^[0-9]'], {
  \ 'on_stdout': function('s:OnComplete'),
  \ 'stdout_buffered': v:true
  \ })

function! s:OnComplete(chan_id, data, event)
  " a:data contains all output as complete lines
  echo 'Complete output:'
  echo a:data
endfunction

Channel Information

List All Channels

Get information about all open channels:
" List all channels
let channels = nvim_list_chans()
for chan in channels
  echo 'Channel ' . chan.id . ': ' . chan.stream
endfor
-- List all channels
local channels = vim.api.nvim_list_chans()
for _, chan in ipairs(channels) do
  print(string.format('Channel %d: %s', chan.id, chan.stream))
end

Get Channel Info

Query detailed information about a specific channel:
" Get info for channel 1
let info = nvim_get_chan_info(1)
echo info
" => {'id': 1, 'stream': 'socket', 'mode': 'rpc', 'client': {...}}
Channel info includes:
  • id - Channel identifier
  • stream - Stream type: stdio, socket, job, stderr
  • mode - Communication mode: bytes, terminal, rpc
  • pty - PTY name (if applicable)
  • buffer - Connected buffer (for terminal)
  • client - Client information (set by nvim_set_client_info())

Example: Find UI Channels

-- Find all UI channels
local channels = vim.api.nvim_list_chans()
for _, chan in ipairs(channels) do
  local info = vim.api.nvim_get_chan_info(chan.id)
  if info.client and info.client.type == 'ui' then
    print(string.format('UI: %s (channel %d)', 
                        info.client.name, chan.id))
  end
end

Sending Data

chansend()

Send raw data to a channel:
" Send to job
call chansend(job_id, "data\n")

" Send multiple lines
call chansend(job_id, ["line1", "line2", "line3"])
-- Send to job
vim.fn.chansend(job_id, 'data\n')

-- Send multiple lines
vim.fn.chansend(job_id, {'line1', 'line2', 'line3'})

nvim_chan_send()

API function for sending data:
-- Send to internal terminal
vim.api.nvim_chan_send(term_chan, 'ls\n')

RPC Functions

For RPC channels, use rpcrequest() and rpcnotify():
" Blocking request (waits for response)
let result = rpcrequest(chan_id, 'nvim_eval', '1 + 1')
echo result  " => 2

" Non-blocking notification
call rpcnotify(chan_id, 'nvim_command', 'echo "Hi"')

Channel Lifecycle

1

Opening

Create channel using jobstart(), sockconnect(), or stdioopen().
local chan = vim.fn.jobstart({'python3', 'script.py'}, {
  on_stdout = handle_output,
})
2

Communicating

Send data with chansend() and receive via callbacks.
vim.fn.chansend(chan, 'input data\n')
3

Closing

Close channel explicitly or it closes when process exits.
-- Close stdin (process can still send output)
vim.fn.chanclose(chan, 'stdin')

-- Stop the job entirely
vim.fn.jobstop(chan)
4

Cleanup

Handle on_exit callback for cleanup.
local chan = vim.fn.jobstart({'python3', 'script.py'}, {
  on_exit = function(id, code, event)
    print('Process exited with code:', code)
    -- Cleanup resources
  end
})

Advanced Patterns

Bidirectional RPC

Embed Nvim in another Nvim instance:
" Start embedded Nvim
let nvim = jobstart(['nvim', '--embed'], {'rpc': v:true})

" Call API on embedded instance
let result = rpcrequest(nvim, 'nvim_eval', '"Hello " . "world!"')
echo result  " => Hello world!

" Set buffer content
call rpcrequest(nvim, 'nvim_buf_set_lines', 0, 0, -1, v:true, 
  \ ['Line 1', 'Line 2'])

" Clean up
call jobstop(nvim)

Channel with Timeout

local chan = vim.fn.jobstart({'long-running-task'}, {
  on_stdout = function(id, data, event)
    print('Output:', vim.inspect(data))
  end
})

-- Kill after timeout
vim.defer_fn(function()
  if vim.fn.jobwait({chan}, 0)[1] == -1 then
    print('Timeout! Killing job.')
    vim.fn.jobstop(chan)
  end
end, 5000)  -- 5 second timeout

Communicating Between Nvim Instances

-- In first Nvim instance
vim.fn.serverstart('/tmp/nvim1.sock')

-- In second Nvim instance
local remote = vim.fn.sockconnect('unix', '/tmp/nvim1.sock', {rpc = true})
local result = vim.fn.rpcrequest(remote, 'nvim_eval', 'getcwd()')
print('Remote directory:', result)

Error Handling

Handle channel errors gracefully:
local chan = vim.fn.jobstart({'python3', 'script.py'}, {
  on_stderr = function(chan_id, data, name)
    if data[1] ~= '' then
      vim.notify('Job error: ' .. table.concat(data, '\n'), 
                 vim.log.levels.ERROR)
    end
  end,
  on_exit = function(chan_id, exit_code, event)
    if exit_code ~= 0 then
      vim.notify(string.format('Job failed with exit code %d', exit_code),
                 vim.log.levels.ERROR)
    end
  end
})

if chan <= 0 then
  vim.notify('Failed to start job', vim.log.levels.ERROR)
end

Best Practices

Performance Tips:
  • Use buffered mode for operations that need complete output
  • Handle partial lines correctly in streaming mode
  • Close channels when done to free resources
  • Use RPC mode for structured communication
Security Considerations:
  • Localhost TCP sockets are less secure than Unix domain sockets
  • RPC channels are implicitly trusted - they can call any API function
  • Validate input from external processes
  • Use v:servername to avoid hardcoding socket paths

Examples

Example: LSP Client Channel

-- Simplified LSP client using channels
local lsp_chan = vim.fn.jobstart(
  {'rust-analyzer'},
  {
    rpc = true,
    on_exit = function()
      print('LSP server exited')
    end
  }
)

-- Initialize LSP
vim.fn.rpcnotify(lsp_chan, 'initialize', {
  processId = vim.fn.getpid(),
  rootUri = 'file://' .. vim.fn.getcwd(),
  capabilities = {}
})

-- Send didOpen notification
vim.fn.rpcnotify(lsp_chan, 'textDocument/didOpen', {
  textDocument = {
    uri = 'file://' .. vim.fn.expand('%:p'),
    languageId = 'rust',
    version = 1,
    text = table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), '\n')
  }
})

Example: REPL Interface

-- Create REPL interface in a buffer
local buf = vim.api.nvim_create_buf(true, false)
vim.bo[buf].buftype = 'prompt'

local repl = vim.fn.jobstart({'python3'}, {
  on_stdout = function(_, data, _)
    if data[1] ~= '' then
      local line = vim.api.nvim_buf_line_count(buf)
      vim.api.nvim_buf_set_lines(buf, line - 1, line - 1, false, data)
    end
  end
})

vim.fn.prompt_setcallback(buf, function(text)
  vim.fn.chansend(repl, text .. '\n')
end)

vim.fn.prompt_setprompt(buf, '>>> ')
vim.cmd('startinsert')

Resources

Build docs developers (and LLMs) love