diff --git a/Cargo.lock b/Cargo.lock
index 9303aa12..19e9ad4a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -322,6 +322,18 @@ dependencies = [
  "unicode-width",
 ]
 
+[[package]]
+name = "helix-dap"
+version = "0.3.0"
+dependencies = [
+ "anyhow",
+ "log",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tokio",
+]
+
 [[package]]
 name = "helix-lsp"
 version = "0.4.1"
diff --git a/Cargo.toml b/Cargo.toml
index 22d29260..53b85042 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ members = [
   "helix-tui",
   "helix-syntax",
   "helix-lsp",
+  "helix-dap"
 ]
 
 # Build helix-syntax in release mode to make the code path faster in development.
diff --git a/helix-dap/Cargo.toml b/helix-dap/Cargo.toml
new file mode 100644
index 00000000..6adaaedd
--- /dev/null
+++ b/helix-dap/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "helix-dap"
+version = "0.3.0"
+authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
+edition = "2018"
+license = "MPL-2.0"
+description = "DAP client implementation for Helix project"
+categories = ["editor"]
+repository = "https://github.com/helix-editor/helix"
+homepage = "https://helix-editor.com"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1.0"
+log = "0.4"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+thiserror = "1.0"
+tokio = { version = "1.9", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] }
diff --git a/helix-dap/examples/dap-basic.rs b/helix-dap/examples/dap-basic.rs
new file mode 100644
index 00000000..45522516
--- /dev/null
+++ b/helix-dap/examples/dap-basic.rs
@@ -0,0 +1,51 @@
+use helix_dap::{Client, Result, SourceBreakpoint};
+
+#[tokio::main]
+pub async fn main() -> Result<()> {
+    let mut client = Client::start("nc", vec!["127.0.0.1", "7777"], 0)?;
+
+    println!("init: {:?}", client.initialize().await);
+    println!("caps: {:?}", client.capabilities());
+    println!(
+        "launch: {:?}",
+        client.launch("/tmp/godebug/main".to_owned()).await
+    );
+
+    println!(
+        "breakpoints: {:?}",
+        client
+            .set_breakpoints(
+                "/tmp/godebug/main.go".to_owned(),
+                vec![SourceBreakpoint {
+                    line: 6,
+                    column: Some(2),
+                }]
+            )
+            .await
+    );
+
+    let mut _in = String::new();
+    std::io::stdin()
+        .read_line(&mut _in)
+        .expect("Failed to read line");
+
+    println!("configurationDone: {:?}", client.configuration_done().await);
+    println!("stopped: {:?}", client.wait_for_stopped().await);
+    println!("stack trace: {:?}", client.stack_trace(1).await);
+
+    let mut _in = String::new();
+    std::io::stdin()
+        .read_line(&mut _in)
+        .expect("Failed to read line");
+
+    println!("continued: {:?}", client.continue_thread(0).await);
+
+    let mut _in = String::new();
+    std::io::stdin()
+        .read_line(&mut _in)
+        .expect("Failed to read line");
+
+    println!("disconnect: {:?}", client.disconnect().await);
+
+    Ok(())
+}
diff --git a/helix-dap/src/client.rs b/helix-dap/src/client.rs
new file mode 100644
index 00000000..9f269a53
--- /dev/null
+++ b/helix-dap/src/client.rs
@@ -0,0 +1,352 @@
+use crate::{
+    transport::{Event, Payload, Request, Response, Transport},
+    Result,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::{from_value, to_value, Value};
+use std::process::Stdio;
+use std::sync::atomic::{AtomicU64, Ordering};
+use tokio::{
+    io::{BufReader, BufWriter},
+    process::{Child, Command},
+    sync::mpsc::{channel, UnboundedReceiver, UnboundedSender},
+};
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DebuggerCapabilities {
+    supports_configuration_done_request: bool,
+    supports_function_breakpoints: bool,
+    supports_conditional_breakpoints: bool,
+    supports_exception_info_request: bool,
+    support_terminate_debuggee: bool,
+    supports_delayed_stack_trace_loading: bool,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct InitializeArguments {
+    client_id: String,
+    client_name: String,
+    adapter_id: String,
+    locale: String,
+    #[serde(rename = "linesStartAt1")]
+    lines_start_at_one: bool,
+    #[serde(rename = "columnsStartAt1")]
+    columns_start_at_one: bool,
+    path_format: String,
+    supports_variable_type: bool,
+    supports_variable_paging: bool,
+    supports_run_in_terminal_request: bool,
+    supports_memory_references: bool,
+    supports_progress_reporting: bool,
+    supports_invalidated_event: bool,
+}
+
+// TODO: split out
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct LaunchArguments {
+    mode: String,
+    program: String,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Source {
+    path: Option<String>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SourceBreakpoint {
+    pub line: usize,
+    pub column: Option<usize>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SetBreakpointsArguments {
+    source: Source,
+    breakpoints: Option<Vec<SourceBreakpoint>>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Breakpoint {
+    pub id: Option<usize>,
+    pub verified: bool,
+    pub message: Option<String>,
+    pub source: Option<Source>,
+    pub line: Option<usize>,
+    pub column: Option<usize>,
+    pub end_line: Option<usize>,
+    pub end_column: Option<usize>,
+    pub instruction_reference: Option<String>,
+    pub offset: Option<usize>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SetBreakpointsResponseBody {
+    breakpoints: Option<Vec<Breakpoint>>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ContinueArguments {
+    thread_id: usize,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ContinueResponseBody {
+    all_threads_continued: Option<bool>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct StackFrameFormat {
+    parameters: Option<bool>,
+    parameter_types: Option<bool>,
+    parameter_names: Option<bool>,
+    parameter_values: Option<bool>,
+    line: Option<bool>,
+    module: Option<bool>,
+    include_all: Option<bool>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct StackTraceArguments {
+    thread_id: usize,
+    start_frame: Option<usize>,
+    levels: Option<usize>,
+    format: Option<StackFrameFormat>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct StackFrame {
+    id: usize,
+    name: String,
+    source: Option<Source>,
+    line: usize,
+    column: usize,
+    end_line: Option<usize>,
+    end_column: Option<usize>,
+    can_restart: Option<bool>,
+    instruction_pointer_reference: Option<String>,
+    // module_id
+    presentation_hint: Option<String>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct StackTraceResponseBody {
+    total_frames: Option<usize>,
+    stack_frames: Vec<StackFrame>,
+}
+
+#[derive(Debug)]
+pub struct Client {
+    id: usize,
+    _process: Child,
+    server_tx: UnboundedSender<Request>,
+    server_rx: UnboundedReceiver<Payload>,
+    request_counter: AtomicU64,
+    capabilities: Option<DebuggerCapabilities>,
+}
+
+impl Client {
+    pub fn start(cmd: &str, args: Vec<&str>, id: usize) -> Result<Self> {
+        let process = Command::new(cmd)
+            .args(args)
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            // make sure the process is reaped on drop
+            .kill_on_drop(true)
+            .spawn();
+
+        let mut process = process?;
+
+        // TODO: do we need bufreader/writer here? or do we use async wrappers on unblock?
+        let writer = BufWriter::new(process.stdin.take().expect("Failed to open stdin"));
+        let reader = BufReader::new(process.stdout.take().expect("Failed to open stdout"));
+
+        let (server_rx, server_tx) = Transport::start(reader, writer, id);
+
+        let client = Self {
+            id,
+            _process: process,
+            server_tx,
+            server_rx,
+            request_counter: AtomicU64::new(0),
+            capabilities: None,
+        };
+
+        // TODO: async client.initialize()
+        // maybe use an arc<atomic> flag
+
+        Ok(client)
+    }
+
+    pub fn id(&self) -> usize {
+        self.id
+    }
+
+    fn next_request_id(&self) -> u64 {
+        self.request_counter.fetch_add(1, Ordering::Relaxed)
+    }
+
+    async fn request(&self, command: String, arguments: Option<Value>) -> Result<Response> {
+        let (callback_rx, mut callback_tx) = channel(1);
+
+        let req = Request {
+            back_ch: Some(callback_rx),
+            seq: self.next_request_id(),
+            msg_type: "request".to_owned(),
+            command,
+            arguments,
+        };
+
+        self.server_tx
+            .send(req)
+            .expect("Failed to send request to debugger");
+
+        callback_tx
+            .recv()
+            .await
+            .expect("Failed to receive response")
+    }
+
+    pub fn capabilities(&self) -> &DebuggerCapabilities {
+        self.capabilities
+            .as_ref()
+            .expect("language server not yet initialized!")
+    }
+
+    pub async fn initialize(&mut self) -> Result<()> {
+        let args = InitializeArguments {
+            client_id: "hx".to_owned(),
+            client_name: "helix".to_owned(),
+            adapter_id: "go".to_owned(),
+            locale: "en-us".to_owned(),
+            lines_start_at_one: true,
+            columns_start_at_one: true,
+            path_format: "path".to_owned(),
+            supports_variable_type: false,
+            supports_variable_paging: false,
+            supports_run_in_terminal_request: false,
+            supports_memory_references: false,
+            supports_progress_reporting: true,
+            supports_invalidated_event: true,
+        };
+
+        let response = self
+            .request("initialize".to_owned(), to_value(args).ok())
+            .await?;
+        self.capabilities = from_value(response.body.unwrap()).ok();
+
+        Ok(())
+    }
+
+    pub async fn disconnect(&mut self) -> Result<()> {
+        self.request("disconnect".to_owned(), None).await?;
+        Ok(())
+    }
+
+    pub async fn launch(&mut self, executable: String) -> Result<()> {
+        let args = LaunchArguments {
+            mode: "exec".to_owned(),
+            program: executable,
+        };
+
+        self.request("launch".to_owned(), to_value(args).ok())
+            .await?;
+
+        match self
+            .server_rx
+            .recv()
+            .await
+            .expect("Expected initialized event")
+        {
+            Payload::Event(Event { event, .. }) => {
+                if event == "initialized".to_owned() {
+                    Ok(())
+                } else {
+                    unreachable!()
+                }
+            }
+            _ => unreachable!(),
+        }
+    }
+
+    pub async fn set_breakpoints(
+        &mut self,
+        file: String,
+        breakpoints: Vec<SourceBreakpoint>,
+    ) -> Result<Option<Vec<Breakpoint>>> {
+        let args = SetBreakpointsArguments {
+            source: Source { path: Some(file) },
+            breakpoints: Some(breakpoints),
+        };
+
+        let response = self
+            .request("setBreakpoints".to_owned(), to_value(args).ok())
+            .await?;
+        let body: Option<SetBreakpointsResponseBody> = from_value(response.body.unwrap()).ok();
+
+        Ok(body.map(|b| b.breakpoints).unwrap())
+    }
+
+    pub async fn configuration_done(&mut self) -> Result<()> {
+        self.request("configurationDone".to_owned(), None).await?;
+        Ok(())
+    }
+
+    pub async fn wait_for_stopped(&mut self) -> Result<()> {
+        match self.server_rx.recv().await.expect("Expected stopped event") {
+            Payload::Event(Event { event, .. }) => {
+                if event == "stopped".to_owned() {
+                    Ok(())
+                } else {
+                    unreachable!()
+                }
+            }
+            _ => unreachable!(),
+        }
+    }
+
+    pub async fn continue_thread(&mut self, thread_id: usize) -> Result<Option<bool>> {
+        let args = ContinueArguments { thread_id };
+
+        let response = self
+            .request("continue".to_owned(), to_value(args).ok())
+            .await?;
+
+        let body: Option<ContinueResponseBody> = from_value(response.body.unwrap()).ok();
+
+        Ok(body.map(|b| b.all_threads_continued).unwrap())
+    }
+
+    pub async fn stack_trace(
+        &mut self,
+        thread_id: usize,
+    ) -> Result<(Vec<StackFrame>, Option<usize>)> {
+        let args = StackTraceArguments {
+            thread_id,
+            start_frame: None,
+            levels: None,
+            format: None,
+        };
+
+        let response = self
+            .request("stackTrace".to_owned(), to_value(args).ok())
+            .await?;
+
+        let body: StackTraceResponseBody = from_value(response.body.unwrap()).unwrap();
+
+        Ok((body.stack_frames, body.total_frames))
+    }
+}
diff --git a/helix-dap/src/lib.rs b/helix-dap/src/lib.rs
new file mode 100644
index 00000000..1e545fd8
--- /dev/null
+++ b/helix-dap/src/lib.rs
@@ -0,0 +1,21 @@
+mod client;
+mod transport;
+
+pub use client::{Breakpoint, Client, SourceBreakpoint};
+pub use transport::{Event, Payload, Request, Response, Transport};
+
+use thiserror::Error;
+#[derive(Error, Debug)]
+pub enum Error {
+    #[error("failed to parse: {0}")]
+    Parse(#[from] serde_json::Error),
+    #[error("IO Error: {0}")]
+    IO(#[from] std::io::Error),
+    #[error("request timed out")]
+    Timeout,
+    #[error("server closed the stream")]
+    StreamClosed,
+    #[error(transparent)]
+    Other(#[from] anyhow::Error),
+}
+pub type Result<T> = core::result::Result<T, Error>;
diff --git a/helix-dap/src/transport.rs b/helix-dap/src/transport.rs
new file mode 100644
index 00000000..1c004cfe
--- /dev/null
+++ b/helix-dap/src/transport.rs
@@ -0,0 +1,282 @@
+use crate::{Error, Result};
+use anyhow::Context;
+use log::error;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use std::sync::Arc;
+use tokio::{
+    io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter},
+    process::{ChildStdin, ChildStdout},
+    sync::{
+        mpsc::{unbounded_channel, Sender, UnboundedReceiver, UnboundedSender},
+        Mutex,
+    },
+};
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Request {
+    #[serde(skip)]
+    pub back_ch: Option<Sender<Result<Response>>>,
+    pub seq: u64,
+    #[serde(rename = "type")]
+    pub msg_type: String,
+    pub command: String,
+    pub arguments: Option<Value>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+pub struct Response {
+    pub seq: u64,
+    #[serde(rename = "type")]
+    pub msg_type: String,
+    pub request_seq: u64,
+    pub success: bool,
+    pub command: String,
+    pub message: Option<String>,
+    pub body: Option<Value>,
+}
+
+#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
+pub struct Event {
+    pub seq: u64,
+    #[serde(rename = "type")]
+    pub msg_type: String,
+    pub event: String,
+    pub body: Option<Value>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum Payload {
+    // type = "event"
+    Event(Event),
+    // type = "response"
+    Response(Response),
+    // type = "request"
+    Request(Request),
+}
+
+#[derive(Debug)]
+pub struct Transport {
+    id: usize,
+    pending_requests: Mutex<HashMap<u64, Sender<Result<Response>>>>,
+}
+
+impl Transport {
+    pub fn start(
+        server_stdout: BufReader<ChildStdout>,
+        server_stdin: BufWriter<ChildStdin>,
+        id: usize,
+    ) -> (UnboundedReceiver<Payload>, UnboundedSender<Request>) {
+        let (client_tx, rx) = unbounded_channel();
+        let (tx, client_rx) = unbounded_channel();
+
+        let transport = Self {
+            id,
+            pending_requests: Mutex::new(HashMap::default()),
+        };
+
+        let transport = Arc::new(transport);
+
+        tokio::spawn(Self::recv(transport.clone(), server_stdout, client_tx));
+        tokio::spawn(Self::send(transport, server_stdin, client_rx));
+
+        (rx, tx)
+    }
+
+    async fn recv_server_message(
+        reader: &mut (impl AsyncBufRead + Unpin + Send),
+        buffer: &mut String,
+    ) -> Result<Payload> {
+        let mut content_length = None;
+        loop {
+            buffer.truncate(0);
+            reader.read_line(buffer).await?;
+            let header = buffer.trim();
+
+            if header.is_empty() {
+                break;
+            }
+
+            let mut parts = header.split(": ");
+
+            match (parts.next(), parts.next(), parts.next()) {
+                (Some("Content-Length"), Some(value), None) => {
+                    content_length = Some(value.parse().context("invalid content length")?);
+                }
+                (Some(_), Some(_), None) => {}
+                _ => {
+                    return Err(std::io::Error::new(
+                        std::io::ErrorKind::Other,
+                        "Failed to parse header",
+                    )
+                    .into());
+                }
+            }
+        }
+
+        let content_length = content_length.context("missing content length")?;
+
+        //TODO: reuse vector
+        let mut content = vec![0; content_length];
+        reader.read_exact(&mut content).await?;
+        let msg = std::str::from_utf8(&content).context("invalid utf8 from server")?;
+
+        // TODO: `info!` here
+        println!("<- DAP {}", msg);
+
+        // try parsing as output (server response) or call (server request)
+        let output: serde_json::Result<Payload> = serde_json::from_str(msg);
+
+        Ok(output?)
+    }
+
+    async fn send_payload_to_server(
+        &self,
+        server_stdin: &mut BufWriter<ChildStdin>,
+        req: Request,
+    ) -> Result<()> {
+        let json = serde_json::to_string(&req)?;
+        match req.back_ch {
+            Some(back) => {
+                self.pending_requests.lock().await.insert(req.seq, back);
+                ()
+            }
+            None => {}
+        }
+        self.send_string_to_server(server_stdin, json).await
+    }
+
+    async fn send_string_to_server(
+        &self,
+        server_stdin: &mut BufWriter<ChildStdin>,
+        request: String,
+    ) -> Result<()> {
+        // TODO: `info!` here
+        println!("-> DAP {}", request);
+
+        // send the headers
+        server_stdin
+            .write_all(format!("Content-Length: {}\r\n\r\n", request.len()).as_bytes())
+            .await?;
+
+        // send the body
+        server_stdin.write_all(request.as_bytes()).await?;
+
+        server_stdin.flush().await?;
+
+        Ok(())
+    }
+
+    async fn process_server_message(
+        &self,
+        client_tx: &UnboundedSender<Payload>,
+        msg: Payload,
+    ) -> Result<()> {
+        let (id, result) = match msg {
+            Payload::Response(Response {
+                success: true,
+                seq,
+                request_seq,
+                ..
+            }) => {
+                // TODO: `info!` here
+                println!("<- DAP success ({}, in response to {})", seq, request_seq);
+                if let Payload::Response(val) = msg {
+                    (request_seq, Ok(val))
+                } else {
+                    unreachable!();
+                }
+            }
+            Payload::Response(Response {
+                success: false,
+                message,
+                body,
+                request_seq,
+                command,
+                ..
+            }) => {
+                // TODO: `error!` here
+                println!(
+                    "<- DAP error {:?} ({:?}) for command #{} {}",
+                    message, body, request_seq, command
+                );
+                (
+                    request_seq,
+                    Err(Error::Other(anyhow::format_err!("{:?}", body))),
+                )
+            }
+            Payload::Request(Request {
+                ref command,
+                ref seq,
+                ..
+            }) => {
+                // TODO: `info!` here
+                println!("<- DAP request {} #{}", command, seq);
+                client_tx.send(msg).expect("Failed to send");
+                return Ok(());
+            }
+            Payload::Event(Event {
+                ref event, ref seq, ..
+            }) => {
+                // TODO: `info!` here
+                println!("<- DAP event {} #{}", event, seq);
+                client_tx.send(msg).expect("Failed to send");
+                return Ok(());
+            }
+        };
+
+        let tx = self
+            .pending_requests
+            .lock()
+            .await
+            .remove(&id)
+            .expect("pending_request with id not found!");
+
+        match tx.send(result).await {
+            Ok(_) => (),
+            Err(_) => error!(
+                "Tried sending response into a closed channel (id={:?}), original request likely timed out",
+                id
+            ),
+        };
+
+        Ok(())
+    }
+
+    async fn recv(
+        transport: Arc<Self>,
+        mut server_stdout: BufReader<ChildStdout>,
+        client_tx: UnboundedSender<Payload>,
+    ) {
+        let mut recv_buffer = String::new();
+        loop {
+            match Self::recv_server_message(&mut server_stdout, &mut recv_buffer).await {
+                Ok(msg) => {
+                    transport
+                        .process_server_message(&client_tx, msg)
+                        .await
+                        .unwrap();
+                }
+                Err(err) => {
+                    error!("err: <- {:?}", err);
+                    break;
+                }
+            }
+        }
+    }
+
+    async fn send(
+        transport: Arc<Self>,
+        mut server_stdin: BufWriter<ChildStdin>,
+        mut client_rx: UnboundedReceiver<Request>,
+    ) {
+        while let Some(req) = client_rx.recv().await {
+            transport
+                .send_payload_to_server(&mut server_stdin, req)
+                .await
+                .unwrap()
+        }
+    }
+}