diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+## [2.7.0](https://github.com/haskell-github-trust/effectful-cli/compare/2.6.0...2.7.0) (2026-11-07)
+
+### Features
+
+* add config functions completions ([4ec43cf](https://github.com/haskell-github-trust/effectful-cli/commit/4ec43cf6177da343e4dc9258598fc131751da502))
+
 ## [2.6.0](https://github.com/haskell-github-trust/effectful-cli/compare/2.5.0...2.6.0) (2026-11-06)
 
 ### Features
diff --git a/effectful-cli.cabal b/effectful-cli.cabal
--- a/effectful-cli.cabal
+++ b/effectful-cli.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           effectful-cli
-version:        0.2.6.0
+version:        0.2.7.0
 synopsis:       CLI helpers for the effectful ecosystem
 description:    Please see the README at https://github.com/haskell-github-trust/effectful-cli
 category:       CLI
diff --git a/src/Data/CLI/Config.hs b/src/Data/CLI/Config.hs
--- a/src/Data/CLI/Config.hs
+++ b/src/Data/CLI/Config.hs
@@ -1,3 +1,4 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TypeFamilies #-}
 
@@ -18,7 +19,7 @@
 import Effectful.CLI.IO (printOutputLn)
 import Effectful.Reader.Static (asks)
 import GHC.Enum (boundedEnumFrom, boundedEnumFromThen)
-import Options.Applicative (Parser, metavar, strArgument)
+import Options.Applicative (Parser, completer, listCompleter, metavar, strArgument)
 import Toml.Schema (FromValue)
 
 -- | Application config @c@ that knows its config file and the type it decodes to.
@@ -31,6 +32,11 @@
   -- | Path of the config file.
   configFilePath :: c -> FilePath
 
+  -- | Known dotted keys, used for shell completion and for hinting when a
+  -- section key is set to a scalar.
+  configKeys :: [Text]
+  configKeys = []
+
 -- | Config file commands for config @c@ and application error @e@. IO failures
 -- are raised as 'InputError'.
 --
@@ -71,15 +77,18 @@
   doc (ConfigSet _ _) = "Set a dotted key to a TOML literal, or a string if not one"
 
   parser ConfigShow = noArgs ConfigShow
-  parser (ConfigGet _) = ConfigGet <$> keyArg
-  parser (ConfigSet _ _) = ConfigSet <$> keyArg <*> strArgument (metavar "VALUE")
+  parser (ConfigGet _) = ConfigGet <$> keyArg (configKeys @c)
+  parser (ConfigSet _ _) = ConfigSet <$> keyArg (configKeys @c) <*> strArgument (metavar "VALUE")
 
   runner cmd = do
     path <- asks @c configFilePath
     rethrowIO (InputError . ("config file: " <>) . T.show) $ case cmd of
       ConfigShow -> printOutputLn . T.stripEnd =<< readConfigText path
       ConfigGet k -> printOutputLn =<< getConfigKey path k
-      ConfigSet k v -> setConfigKey @(ConfigOf c) path k v
+      ConfigSet k v -> setConfigKey @(ConfigOf c) (configKeys @c) path k v
 
-keyArg :: Parser Text
-keyArg = strArgument (metavar "KEY")
+keyArg
+  :: [Text]
+  -> Parser Text
+keyArg keys =
+  strArgument (metavar "KEY" <> completer (listCompleter (fmap T.unpack keys)))
diff --git a/src/Effectful/CLI/Config.hs b/src/Effectful/CLI/Config.hs
--- a/src/Effectful/CLI/Config.hs
+++ b/src/Effectful/CLI/Config.hs
@@ -67,26 +67,38 @@
 -- tables; the file is only written if the result still decodes as @a@.
 -- Re-renders the whole file, dropping comments and formatting.
 --
+-- When decoding fails and @key@ is a section of one of the known keys, the
+-- error says which keys to set instead.
+--
 -- @since 0.2.6.0
+--
+-- The known-keys argument was added in 0.2.7.0.
 setConfigKey
   :: forall a es
    . FromValue a
   => IOE :> es
   => Error CLIError :> es
-  => FilePath
+  => [Text]
+  -> FilePath
   -> Text
   -> Text
   -> Eff es ()
-setConfigKey path key raw = do
+setConfigKey known path key raw = do
   table <-
     either (throwError . InputError) pure . setKey (T.splitOn "." key) (parseValue raw)
       =<< readTable path
   let rendered = T.show (prettyToml table)
   case Toml.decode @a rendered of
-    Failure es -> throwError (InputError ("invalid config: " <> T.pack (unlines es)))
+    Failure es ->
+      throwError
+        (InputError ("invalid config: " <> T.stripEnd (T.pack (unlines es)) <> sectionHint))
     Success _ _ -> liftIO $ do
       createDirectoryIfMissing True (takeDirectory path)
       T.writeFile path rendered
+ where
+  sectionHint = case filter (T.isPrefixOf (key <> ".")) known of
+    [] -> ""
+    ks -> "; " <> key <> " is a section, set one of: " <> T.intercalate ", " ks
 
 readTable
   :: IOE :> es
diff --git a/test/Spec.hs b/test/Spec.hs
--- a/test/Spec.hs
+++ b/test/Spec.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE DataKinds #-}
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 
 import Data.CLI.Impl (
@@ -24,6 +25,7 @@
 import Test.Hspec
 import Text.Megaparsec.Char (string)
 import Toml (Table, Table' (..), Value' (..))
+import Toml.Schema (FromValue (..), parseTableFromValue, reqKey, reqKeyOf)
 
 isInputError :: Either CLIError a -> Bool
 isInputError (Left (InputError _)) = True
@@ -136,7 +138,21 @@
     it "writes then reads a key through the file" $ do
       path <- (</> "effectful-cli-config-test.toml") <$> getTemporaryDirectory
       r <- runEff . runErrorNoCallStack @CLIError $ do
-        setConfigKey @Table path "a.b" "1"
+        setConfigKey @Table [] path "a.b" "1"
         getConfigKey path "a.b"
       removeFile path
       r `shouldBe` Right "1"
+
+    it "hints at known keys when a section is set to a scalar" $ do
+      path <- (</> "effectful-cli-config-hint-test.toml") <$> getTemporaryDirectory
+      r <-
+        runEff . runErrorNoCallStack @CLIError $
+          setConfigKey @Section ["sec.x", "sec.y", "other.z"] path "sec" "plain"
+      r `shouldSatisfy` \case
+        Left (InputError m) -> "sec is a section, set one of: sec.x, sec.y" `isInfixOf` show m
+        _ -> False
+
+newtype Section = Section Int
+
+instance FromValue Section where
+  fromValue = parseTableFromValue (Section <$> reqKeyOf "sec" (parseTableFromValue (reqKey "x")))
