diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,39 @@
 
 ## [Unreleased]
 
+## [baikai 0.1.1.0] - 2026-06-12
+
+### Added
+
+- Added provider-agnostic `ResponseFormat` support on `Options`, including
+  plain JSON-object mode and named JSON-schema mode.
+- Added `Baikai.Embedding`, an OpenAI `/v1/embeddings` client for text
+  embeddings.
+
+## [baikai-claude 0.1.1.0] - 2026-06-12
+
+### Added
+
+- Mapped baikai `ResponseFormat` options onto Anthropic `output_config` for
+  Claude API requests.
+- Exported `mapRequest` for request-mapping tests and downstream inspection.
+
+## [baikai-openai 0.1.1.0] - 2026-06-12
+
+### Added
+
+- Mapped baikai `ResponseFormat` options onto OpenAI Chat Completions
+  `response_format`.
+- Exported `mapRequest` for request-mapping tests and downstream inspection.
+
+## [baikai-effectful 0.1.0.0] - 2026-06-12
+
+### Added
+
+- Initial release: effectful binding for baikai with the `Baikai` dynamic
+  effect, `complete`, `streamCollect`, `streamEach`, and registry-backed
+  interpreters.
+
 ## [baikai 0.1.0.0] - 2026-06-04
 
 ### Added
diff --git a/baikai.cabal b/baikai.cabal
--- a/baikai.cabal
+++ b/baikai.cabal
@@ -1,6 +1,6 @@
 cabal-version:   3.4
 name:            baikai
-version:         0.1.0.0
+version:         0.1.1.0
 synopsis:        Unified Haskell interface for multiple AI providers
 description:
   baikai provides a unified, provider-agnostic Haskell interface for working
@@ -47,6 +47,7 @@
     Baikai.Cost
     Baikai.Cost.Log
     Baikai.Cost.Pricing
+    Baikai.Embedding
     Baikai.Error
     Baikai.Interactive
     Baikai.Message
@@ -58,6 +59,7 @@
     Baikai.Provider.Cli.Internal
     Baikai.Provider.Registry
     Baikai.Response
+    Baikai.ResponseFormat
     Baikai.StopReason
     Baikai.Stream
     Baikai.Stream.Event
@@ -76,6 +78,7 @@
     , containers
     , generic-lens
     , lens               ^>=5.3
+    , openai
     , scientific
     , streamly           >=0.11 && <0.13
     , streamly-core      >=0.3  && <0.5
@@ -108,6 +111,7 @@
     AgentAssetsSpec
     CatalogSpec
     CostSpec
+    EmbeddingSpec
     InteractiveSpec
     TraceSpec
 
@@ -119,6 +123,7 @@
     , bytestring
     , directory
     , filepath
+    , openai
     , process
     , stm
     , streamly-core  >=0.3 && <0.5
diff --git a/src/Baikai.hs b/src/Baikai.hs
--- a/src/Baikai.hs
+++ b/src/Baikai.hs
@@ -23,6 +23,7 @@
     -- * Per-API compat shims and call-time options
     module Baikai.Compat,
     module Baikai.CacheRetention,
+    module Baikai.ResponseFormat,
     module Baikai.ThinkingLevel,
 
     -- * Provider registry
@@ -49,6 +50,7 @@
 import Baikai.Options
 import Baikai.Provider
 import Baikai.Response
+import Baikai.ResponseFormat
 import Baikai.StopReason
 import Baikai.Stream
 import Baikai.Stream.Event
diff --git a/src/Baikai/Embedding.hs b/src/Baikai/Embedding.hs
new file mode 100644
--- /dev/null
+++ b/src/Baikai/Embedding.hs
@@ -0,0 +1,105 @@
+-- | A small, provider-neutral embeddings client over an OpenAI-compatible
+-- @\/v1\/embeddings@ endpoint (EP-15).
+--
+-- baikai shipped no embeddings client; this is the first. It reuses the same
+-- @openai@ SDK path the OpenAI /chat/ provider already uses
+-- ('OpenAI.V1.getClientEnv' + 'OpenAI.V1.makeMethods') and the sibling
+-- 'OpenAI.V1.createEmbeddings' method, plus baikai's own 'Baikai.Auth' for key
+-- resolution. It is policy-free (a plain @IO@ client, no effect binding) — the
+-- effect interpreter lives one layer up in shikumi, exactly as @baikai-effectful@
+-- relates to the transport.
+--
+-- An embedding model is named by a bare provider model-id string (e.g.
+-- @\"text-embedding-3-small\"@) plus a base URL inside 'EmbeddingModel'; there is
+-- no @Api@ tag and no chat-catalog entry, because none of the chat 'Baikai.Model'
+-- fields (context window, output tokens, chat pricing, modalities) are meaningful
+-- for embeddings.
+module Baikai.Embedding
+  ( EmbeddingModel (..),
+    _EmbeddingModel,
+    openAIEmbeddingModel,
+    mkEmbeddingRequest,
+    embed,
+    embedOne,
+  )
+where
+
+import Baikai.Auth (ApiKeySource (..), resolveApiKey)
+import Data.Text (Text)
+import Data.Vector (Vector)
+import Data.Vector qualified as V
+import Numeric.Natural (Natural)
+import OpenAI.V1 qualified as OpenAI
+import OpenAI.V1.Embeddings qualified as Emb
+import OpenAI.V1.Models qualified as OpenAIModels
+
+-- | How to reach an embeddings endpoint and which model to ask for.
+data EmbeddingModel = EmbeddingModel
+  { -- | e.g. @\"text-embedding-3-small\"@
+    modelId :: !Text,
+    -- | e.g. @\"https:\/\/api.openai.com\"@ (empty = the OpenAI default)
+    baseUrl :: !Text,
+    -- | request a reduced dimensionality, or 'Nothing' for the model default
+    dimensions :: !(Maybe Natural),
+    -- | how to resolve the API key (from "Baikai.Auth")
+    apiKey :: !ApiKeySource
+  }
+  deriving stock (Show)
+
+-- | A blank embedding model; a record-update target for hand-built models. Keyed
+-- on @OPENAI_API_KEY@ by default.
+_EmbeddingModel :: EmbeddingModel
+_EmbeddingModel =
+  EmbeddingModel
+    { modelId = "",
+      baseUrl = "",
+      dimensions = Nothing,
+      apiKey = ApiKeyEnv "OPENAI_API_KEY"
+    }
+
+-- | The OpenAI default: @api.openai.com@, key from @OPENAI_API_KEY@, model-default
+-- dimensionality.
+openAIEmbeddingModel :: Text -> EmbeddingModel
+openAIEmbeddingModel mid =
+  _EmbeddingModel
+    { modelId = mid,
+      baseUrl = "https://api.openai.com",
+      dimensions = Nothing,
+      apiKey = ApiKeyEnv "OPENAI_API_KEY"
+    }
+
+-- | Build the OpenAI @\/v1\/embeddings@ request for a single input text. Pure and
+-- exported so it can be unit-tested without a network. (@embed@ calls it.)
+mkEmbeddingRequest :: EmbeddingModel -> Text -> Emb.CreateEmbeddings
+mkEmbeddingRequest m t =
+  Emb._CreateEmbeddings
+    { Emb.input = t,
+      Emb.model = OpenAIModels.Model (modelId m),
+      Emb.dimensions = dimensions m
+    }
+
+-- | Embed a batch of texts: one vector per input text, in input order. The SDK's
+-- @CreateEmbeddings.input@ is a single 'Text', so this loops one call per text. The
+-- transport exception (a Servant client error) is let propagate — error remapping
+-- is the consumer's job (in shikumi the interpreter wraps it as a typed error).
+embed :: EmbeddingModel -> [Text] -> IO (Vector (Vector Double))
+embed _ [] = pure V.empty
+embed m texts = do
+  key <- resolveApiKey (apiKey m)
+  env <- OpenAI.getClientEnv (urlOf m)
+  let create = OpenAI.createEmbeddings (OpenAI.makeMethods env key Nothing Nothing)
+  V.fromList <$> traverse (embedText create) texts
+  where
+    embedText create t = do
+      objs <- create (mkEmbeddingRequest m t)
+      pure (Emb.embedding (V.head objs))
+
+-- | Embed a single text.
+embedOne :: EmbeddingModel -> Text -> IO (Vector Double)
+embedOne m t = V.head <$> embed m [t]
+
+-- | Substitute the OpenAI default for an empty base URL (as the chat provider does).
+urlOf :: EmbeddingModel -> Text
+urlOf m = case baseUrl m of
+  "" -> "https://api.openai.com"
+  u -> u
diff --git a/src/Baikai/Options.hs b/src/Baikai/Options.hs
--- a/src/Baikai/Options.hs
+++ b/src/Baikai/Options.hs
@@ -10,7 +10,9 @@
 -- EP-4 added @toolChoice@. EP-5 adds @cacheRetention@ and @thinking@
 -- (provider-agnostic preferences that each provider maps to its own
 -- primitive — see 'Baikai.CacheRetention' and 'Baikai.ThinkingLevel'
--- for the mappings).
+-- for the mappings). EP-2 (shikumi) adds @responseFormat@, the
+-- provider-agnostic structured-output preference — see
+-- 'Baikai.ResponseFormat'.
 module Baikai.Options
   ( Options (..),
     _Options,
@@ -19,6 +21,7 @@
 
 import Baikai.Auth (ApiKeySource)
 import Baikai.CacheRetention (CacheRetention)
+import Baikai.ResponseFormat (ResponseFormat)
 import Baikai.ThinkingLevel (ThinkingLevel)
 import Baikai.Tool (ToolChoice)
 import Data.Aeson (ToJSON, Value)
@@ -37,7 +40,8 @@
     metadata :: !(Map Text Value),
     toolChoice :: !(Maybe ToolChoice),
     cacheRetention :: !(Maybe CacheRetention),
-    thinking :: !(Maybe ThinkingLevel)
+    thinking :: !(Maybe ThinkingLevel),
+    responseFormat :: !(Maybe ResponseFormat)
   }
   deriving stock (Eq, Show, Generic)
   deriving anyclass (ToJSON)
@@ -53,5 +57,6 @@
       metadata = Map.empty,
       toolChoice = Nothing,
       cacheRetention = Nothing,
-      thinking = Nothing
+      thinking = Nothing,
+      responseFormat = Nothing
     }
diff --git a/src/Baikai/ResponseFormat.hs b/src/Baikai/ResponseFormat.hs
new file mode 100644
--- /dev/null
+++ b/src/Baikai/ResponseFormat.hs
@@ -0,0 +1,38 @@
+{-# OPTIONS_GHC -Wno-partial-fields #-}
+
+-- | Provider-agnostic structured-output preference.
+--
+-- Each provider maps the value to its own native mechanism:
+-- OpenAI's @response_format@ and Anthropic's @output_config@.
+-- 'Nothing' on 'Baikai.Options.responseFormat' means no
+-- structured-output constraint (today's behaviour).
+module Baikai.ResponseFormat
+  ( ResponseFormat (..),
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON, Value)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+-- | How to constrain the model's output.
+data ResponseFormat
+  = -- | Enforce a named JSON Schema. The 'schema' is a raw JSON
+    --   Schema document (an aeson 'Value'), passed through verbatim;
+    --   baikai never inspects or validates it. 'strict' requests the
+    --   provider's strict schema-enforcement mode where available
+    --   (OpenAI honours it; Anthropic structured outputs are always
+    --   schema-enforcing and ignore it).
+    JsonSchema
+      { name :: !Text,
+        schema :: !Value,
+        strict :: !Bool
+      }
+  | -- | Plain-JSON mode: the model must emit syntactically valid JSON
+    --   but is not constrained to a specific shape. Maps to OpenAI's
+    --   @{"type":"json_object"}@; on Anthropic (whose structured
+    --   outputs require a schema) it maps to a permissive
+    --   @{"type":"object"}@ schema.
+    JsonObject
+  deriving stock (Eq, Show, Generic)
+  deriving anyclass (FromJSON, ToJSON)
diff --git a/test/EmbeddingSpec.hs b/test/EmbeddingSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/EmbeddingSpec.hs
@@ -0,0 +1,34 @@
+-- | Tests for the embeddings client (EP-15, M1).
+--
+-- The request-mapping test is hermetic: it asserts on the pure
+-- 'mkEmbeddingRequest' (no network), proving the input text, model id, and
+-- dimensions land where the OpenAI @\/v1\/embeddings@ wire expects them. The live
+-- test is gated on @BAIKAI_EMBEDDING_LIVE=1@ (and a real @OPENAI_API_KEY@) so the
+-- default run stays offline.
+module EmbeddingSpec (tests) where
+
+import Baikai.Embedding (embedOne, mkEmbeddingRequest, openAIEmbeddingModel)
+import Data.Vector qualified as V
+import OpenAI.V1.Embeddings qualified as Emb
+import OpenAI.V1.Models qualified as OpenAIModels
+import System.Environment (lookupEnv)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+tests :: TestTree
+tests =
+  testGroup
+    "Baikai.Embedding"
+    [ testCase "mkEmbeddingRequest maps input, model id, and dimensions" $ do
+        let req = mkEmbeddingRequest (openAIEmbeddingModel "text-embedding-3-small") "hello"
+        Emb.input req @?= "hello"
+        OpenAIModels.text (Emb.model req) @?= "text-embedding-3-small"
+        Emb.dimensions req @?= Nothing,
+      testCase "live embedding returns a 1536-length vector" $ do
+        live <- lookupEnv "BAIKAI_EMBEDDING_LIVE"
+        case live of
+          Just "1" -> do
+            v <- embedOne (openAIEmbeddingModel "text-embedding-3-small") "hello"
+            V.length v @?= 1536
+          _ -> putStrLn "BAIKAI_EMBEDDING_LIVE not set; skipping live test"
+    ]
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -10,6 +10,7 @@
 import Data.ByteString.Lazy.Char8 qualified as LBS8
 import Data.Text qualified as Text
 import Data.Vector qualified as V
+import EmbeddingSpec qualified
 import InteractiveSpec qualified
 import Streamly.Data.Stream qualified as Stream
 import Test.Tasty (TestTree, defaultMain, testGroup)
@@ -76,6 +77,7 @@
         AgentAssetsSpec.tests,
         CatalogSpec.tests,
         CostSpec.tests,
+        EmbeddingSpec.tests,
         InteractiveSpec.tests,
         TraceSpec.tests
       ]
@@ -90,7 +92,18 @@
       testCase "_Options defaults are zero-y" $ do
         _Options ^. #maxTokens @?= Nothing
         _Options ^. #temperature @?= Nothing
-        _Options ^. #apiKey @?= Nothing,
+        _Options ^. #apiKey @?= Nothing
+        _Options ^. #responseFormat @?= Nothing,
+      testCase "responseFormat round-trips through Options" $ do
+        responseFormat (_Options & #responseFormat .~ Just JsonObject)
+          @?= Just JsonObject
+        let person =
+              Aeson.object
+                [ "type" Aeson..= ("object" :: Text)
+                ]
+            schemaFmt = JsonSchema {name = "person", schema = person, strict = True}
+        responseFormat (_Options & #responseFormat .~ Just schemaFmt)
+          @?= Just schemaFmt,
       testCase "Options Show redacts literal API keys" $ do
         let secret = "sk-baikai-secret-never-print"
             opts = _Options & #apiKey .~ Just (ApiKeyLiteral secret)
